Spaces:
Sleeping
Sleeping
Upload folder using huggingface_hub
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- Dockerfile +31 -0
- Dockerfile.runner +5 -0
- FLAKEFORGE_WRITEUP.md +472 -0
- README.md +836 -10
- __init__.py +74 -0
- _bench.py +65 -0
- _curate_and_install.py +262 -0
- _ff_diag.txt +0 -0
- _tmp_inventory.py +29 -0
- agent/__init__.py +25 -0
- agent/observation_utils.py +95 -0
- agent/unified_agent.py +739 -0
- build_idoft_dataset.py +711 -0
- client.py +148 -0
- inference.py +933 -0
- models.py +358 -0
- openenv.yaml +7 -0
- openenv_FlakeForge.egg-info/PKG-INFO +22 -0
- openenv_FlakeForge.egg-info/SOURCES.txt +41 -0
- openenv_FlakeForge.egg-info/dependency_links.txt +1 -0
- openenv_FlakeForge.egg-info/entry_points.txt +2 -0
- openenv_FlakeForge.egg-info/requires.txt +18 -0
- openenv_FlakeForge.egg-info/top_level.txt +1 -0
- push_space.py +12 -0
- pyproject.toml +50 -0
- pytest.ini +4 -0
- run_curriculum.py +72 -0
- server/FlakeForge_environment.py +1196 -0
- server/__init__.py +18 -0
- server/app.py +80 -0
- server/causal_graph.py +527 -0
- server/chaos_runner.py +226 -0
- server/deep_flakiness.py +378 -0
- server/docker_runner.py +371 -0
- server/oracle_engine.py +886 -0
- server/patch_applier.py +1060 -0
- server/patch_validator.py +627 -0
- server/perf_sentinel.py +284 -0
- server/requirements.docker-runner.txt +10 -0
- server/requirements.txt +15 -0
- server/reward.py +447 -0
- server/state.py +108 -0
- server/tools.py +1214 -0
- templates/index.html +1126 -0
- tests/test_action_and_judge_regressions.py +309 -0
- tests/test_flakeforge_integration.py +473 -0
- tests/visualize_agent_tool_usage.py +197 -0
- tests/visualize_causal_graph_usage.py +189 -0
- tests/visualize_deep_flakiness_usage.py +198 -0
- training-requirements.txt +10 -0
Dockerfile
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Copyright (c) Meta Platforms, Inc. and affiliates.
|
| 2 |
+
# All rights reserved.
|
| 3 |
+
#
|
| 4 |
+
# This source code is licensed under the BSD-style license found in the
|
| 5 |
+
# LICENSE file in the root directory of this source tree.
|
| 6 |
+
|
| 7 |
+
FROM python:3.11-slim
|
| 8 |
+
|
| 9 |
+
WORKDIR /app
|
| 10 |
+
|
| 11 |
+
RUN apt-get update \
|
| 12 |
+
&& apt-get install -y --no-install-recommends \
|
| 13 |
+
git \
|
| 14 |
+
curl \
|
| 15 |
+
stress-ng \
|
| 16 |
+
iproute2 \
|
| 17 |
+
&& rm -rf /var/lib/apt/lists/*
|
| 18 |
+
|
| 19 |
+
COPY server/requirements.txt /app/server/requirements.txt
|
| 20 |
+
RUN pip install --no-cache-dir -r /app/server/requirements.txt
|
| 21 |
+
|
| 22 |
+
COPY . /app/
|
| 23 |
+
|
| 24 |
+
HEALTHCHECK --interval=10s --timeout=3s --start-period=10s --retries=5 \
|
| 25 |
+
CMD curl -f http://localhost:8000/health || exit 1
|
| 26 |
+
|
| 27 |
+
EXPOSE 8000
|
| 28 |
+
|
| 29 |
+
ENTRYPOINT ["uvicorn", "server.app:app", "--host", "0.0.0.0", "--port", "8000"]
|
| 30 |
+
|
| 31 |
+
ENV ENABLE_WEB_INTERFACE=true
|
Dockerfile.runner
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
FROM python:3.11-slim
|
| 2 |
+
WORKDIR /workspace
|
| 3 |
+
COPY server/requirements.docker-runner.txt /tmp/req.txt
|
| 4 |
+
RUN pip install --no-cache-dir -r /tmp/req.txt
|
| 5 |
+
# Default command is unused; FlakeForge passes pytest on docker run
|
FLAKEFORGE_WRITEUP.md
ADDED
|
@@ -0,0 +1,472 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# FlakeForge: Teaching an RL Agent to Repair Flaky Tests
|
| 2 |
+
|
| 3 |
+
Flaky tests silently break CI pipelines.
|
| 4 |
+
|
| 5 |
+
They pass on one run, fail on the next, and leave developers asking the worst debugging question: "What changed?" Most teams respond with retries, larger timeouts, skipped tests, or long manual investigations. Those fixes may quiet the pipeline, but they rarely explain the real root cause.
|
| 6 |
+
|
| 7 |
+
FlakeForge was built around a different idea: treat flaky-test repair as an interactive reinforcement-learning environment, where an agent must inspect evidence, propose a causal explanation, apply a minimal patch, and prove the fix through repeated execution.
|
| 8 |
+
|
| 9 |
+
The goal is not to make a model sound confident. The goal is to make the test suite stable.
|
| 10 |
+
|
| 11 |
+
---
|
| 12 |
+
|
| 13 |
+
## 1. The Problem: Flaky Tests Are Not Normal Bugs
|
| 14 |
+
|
| 15 |
+
A deterministic bug is straightforward compared with a flaky one. If a test fails every time, the failure is at least reproducible. You can trace it, patch it, and rerun it.
|
| 16 |
+
|
| 17 |
+
Flaky tests are different. They live in the gaps between runs:
|
| 18 |
+
|
| 19 |
+
- A timeout appears only when the machine is under load.
|
| 20 |
+
- A shared global counter keeps state from a previous test.
|
| 21 |
+
- A mock remains patched after teardown.
|
| 22 |
+
- A test passes alone but fails when another test runs first.
|
| 23 |
+
- A socket, file handle, thread, or event loop survives longer than expected.
|
| 24 |
+
- A dictionary, set, random seed, timestamp, or scheduler changes the execution path.
|
| 25 |
+
|
| 26 |
+
This is why flaky tests are so expensive. The failure is not just in the code. It is in the interaction between code, runtime, order, timing, environment, and test harness.
|
| 27 |
+
|
| 28 |
+
The painful part is that the symptom often points to the wrong place. A stack trace may end at an assertion, but the cause may be a stale cache, an import side effect, a fixture scope leak, or a race several calls earlier.
|
| 29 |
+
|
| 30 |
+
That makes flaky-test repair a strong environment-design problem: the agent needs observations that expose hidden state and transitions that verify whether a patch actually improved stability.
|
| 31 |
+
|
| 32 |
+
---
|
| 33 |
+
|
| 34 |
+
## 2. Why Existing Solutions Fall Short
|
| 35 |
+
|
| 36 |
+
Most teams already have tools for flaky tests, but each one solves only part of the problem.
|
| 37 |
+
|
| 38 |
+
### Retries Hide the Symptom
|
| 39 |
+
|
| 40 |
+
Retries are useful for reducing CI noise, but they are not a repair strategy. If a test passes after three attempts, the pipeline turns green while the underlying nondeterminism remains. Over time, the project accumulates tests that are "green enough" instead of trustworthy.
|
| 41 |
+
|
| 42 |
+
### Static Analysis Misses Runtime Behavior
|
| 43 |
+
|
| 44 |
+
Static analysis can flag risky patterns such as mutable globals, unsafe fixture scopes, unclosed resources, or import-time side effects. But flaky behavior often depends on execution order, scheduling, timing, and environment pressure. A static warning can tell us where to look, not whether the test is actually unstable.
|
| 45 |
+
|
| 46 |
+
### Heuristic Debugging Does Not Learn
|
| 47 |
+
|
| 48 |
+
Rule-based tools can catch common cases, but they struggle when the fix requires combining signals: stack traces, repeated pass rates, failure entropy, causal call chains, and patch validation. A flaky bug may look like a timeout but actually be a race. It may look like a network issue but actually be a missing mock.
|
| 49 |
+
|
| 50 |
+
### LLM-Only Repair Is Too Easy to Game
|
| 51 |
+
|
| 52 |
+
A language model can generate plausible explanations and patches, but plausible is not enough. If the reward is another model's opinion, the agent can learn to write convincing narratives instead of correct repairs. In software repair, the final judge should be executable evidence.
|
| 53 |
+
|
| 54 |
+
FlakeForge is designed around that principle: reward what can be verified.
|
| 55 |
+
|
| 56 |
+
---
|
| 57 |
+
|
| 58 |
+
## 3. How We Got Here: Two Heads, a Judge, and the Unified Bet
|
| 59 |
+
|
| 60 |
+
The current FlakeForge design did not land on the first try. The path from prototype to a trainable environment taught us as much as the final architecture.
|
| 61 |
+
|
| 62 |
+
### The first architecture: analyzer and fixer (two separate roles)
|
| 63 |
+
|
| 64 |
+
We started with a **split system**: one component played **analyzer** (diagnose the flaky test, name a root cause) and another played **fixer** (propose a patch). The intuition was classic divide-and-conquer: specialization should make each part easier to train or prompt.
|
| 65 |
+
|
| 66 |
+
In practice, **segregated roles created friction**.
|
| 67 |
+
|
| 68 |
+
- **Hand-offs broke coherence.** The fixer only saw what the analyzer chose to pass along. A slightly wrong label or a missing detail cascaded into the wrong file or the wrong kind of edit.
|
| 69 |
+
- **Responsibility blurred.** When something failed, it was unclear whether the analyzer misread the failure, the fixer misapplied the intent, or the task was impossible in one step.
|
| 70 |
+
- **Training signals split awkwardly.** Rewarding "good analysis" and "good patching" separately does not match how engineers debug: they hypothesize and edit in a tight loop, and they revise the hypothesis when the run disagrees.
|
| 71 |
+
|
| 72 |
+
We asked a simple question: **why separate what humans do in one mental thread?** That pushed us toward a **unified agent** that emits both diagnosis and patch in a single structured action, so the model can keep hypothesis and edit aligned at every step.
|
| 73 |
+
|
| 74 |
+
### Adding a judge: help that became a bottleneck
|
| 75 |
+
|
| 76 |
+
To stabilize the two-head design, we introduced a **third model** that acted as a **judge**: it critiqued the analyzer and fixer and steered the next move.
|
| 77 |
+
|
| 78 |
+
The theory was sound—explicit critique can catch inconsistent plans—but the system paid a heavy price in practice.
|
| 79 |
+
|
| 80 |
+
- **Inference cost tripled** in the worst case. Each turn required reasoning traces from the analyzer, the fixer, and the judge. Latency and token budgets ballooned before we even measured learning.
|
| 81 |
+
- **The reward story weakened at training time.** The judge was wired mainly for **inference-time orchestration**, not a closed loop with the same **verifiable** reward we wanted for GRPO. The two roles were **guided** by the judge, but we never had a **first-class way to supervise the judge**—no one consistently "checked the checker." A weak or biased judge could steer the team without paying the same cost as a bad patch.
|
| 82 |
+
- **The learning objective drifted** toward "please the critic" instead of "stabilize the test under repeated `pytest`."
|
| 83 |
+
|
| 84 |
+
That experience pushed a hard rule: **the environment must be the final authority**, not a third language model. Static checks, test outcomes, and explicit reward terms are cheaper to audit than another opaque verdict.
|
| 85 |
+
|
| 86 |
+
### The fork in the road: detection, big models, and a different goal
|
| 87 |
+
|
| 88 |
+
We also confronted an honest product question. **Detecting** flaky tests is genuinely hard. If we solved detection well, a tempting shortcut is: *call a large general-purpose code model to patch, or even stop at flagging the test and hand off to a human.*
|
| 89 |
+
|
| 90 |
+
That path is defensible, but it would have **narrowed the story to detection** or to **"big model does everything."** We chose a more ambitious target:
|
| 91 |
+
|
| 92 |
+
> Show that a **smaller** code model (on the order of **7B–8B parameters**), **trained in a specialized FlakeForge environment**—with preflight, repeated runs, causal and deep flakiness signals, and **verifiable reward**—can **match or beat** **zero-shot** prompting of **much larger** models on **detection and repair together**.
|
| 93 |
+
|
| 94 |
+
In other words: the win is not "we used the biggest model." The win is **a compact policy that internalizes what makes flaky tests flaky** because it was trained where flaky behavior is *measured*, not only described.
|
| 95 |
+
|
| 96 |
+
That is where brainstorming converged: **unified structured actions**, **no LLM judge in the training loop**, **environment-grounded reward**, and a curriculum (IDoFT plus synthetic cases) that teaches the model to read this problem better than a generic chat-style agent.
|
| 97 |
+
|
| 98 |
+
The sections that follow are the architecture, environment, and training stack that grew out of that bet.
|
| 99 |
+
|
| 100 |
+
---
|
| 101 |
+
|
| 102 |
+
## 4. The Core Idea
|
| 103 |
+
|
| 104 |
+
FlakeForge turns flaky-test repair into a verifiable RL environment.
|
| 105 |
+
|
| 106 |
+
At each step, the agent receives a structured observation, emits a diagnosis and patch, and the environment applies the patch, reruns the target test repeatedly, validates the result, and returns a scalar reward.
|
| 107 |
+
|
| 108 |
+
The system is built around five ideas:
|
| 109 |
+
|
| 110 |
+
- The agent should produce both a root-cause explanation and a patch in one structured action.
|
| 111 |
+
- The environment should detect whether a test is truly flaky before spending training credit.
|
| 112 |
+
- Reward should come mainly from repeated `pytest` outcomes, not subjective LLM judgment.
|
| 113 |
+
- Patches should be minimal, applicable, syntax-valid, and near the failure frontier.
|
| 114 |
+
- Reward hacking should be penalized: no skipping tests, swallowing exceptions, weakening assertions, or adding sleeps as fake fixes.
|
| 115 |
+
|
| 116 |
+
In short: FlakeForge trains a model to behave less like an autocomplete tool and more like a debugging loop.
|
| 117 |
+
|
| 118 |
+
---
|
| 119 |
+
|
| 120 |
+
## 5. The Middle of the Build: Flaky vs Buggy, Two-Part Reward, and Why Validation Is First
|
| 121 |
+
|
| 122 |
+
Once we moved to a **unified** agent, the next hard question was: **what exactly do we reward, and in what order?** The action has two human-readable halves—**structured thinking** (claims: category, entity, `path::function`, and a reason) and **search/replace patches** tied to real file locations. The reward system had to value **both** without letting fluent “thinking” stand in for a bad edit, or a lucky edit stand in for nonsense diagnosis.
|
| 123 |
+
|
| 124 |
+
### Flaky training only: why pass rate alone is not enough
|
| 125 |
+
|
| 126 |
+
A core constraint was: **we did not want to train a flaky-test fixer on ordinary buggy code.** The scenario we care about is *instability across runs*, not a test that is deterministically red because the product code is simply wrong.
|
| 127 |
+
|
| 128 |
+
The natural first idea is to run the target test **n** times and look at the **pass rate**. That helps, but it is not a **strong** classifier on its own:
|
| 129 |
+
|
| 130 |
+
- A **deterministic** bug can show up as `pass_rate = 0` for the first *n* runs, yet pass on run *n + 1* in principle if something external changes—so `0%` is **not** a reliable label for “definitely not flaky; definitely buggy.”
|
| 131 |
+
- Conversely, a genuinely flaky test might look bad in a small batch of runs by chance.
|
| 132 |
+
|
| 133 |
+
So we **prioritized what the test harness actually said** in **logs and error types**: e.g. **import errors** (`ModuleNotFoundError`, broken collectors), **syntax errors**, and other signals that the checkout or the code is **not even runnable** point to “this episode is not a clean flaky-repair target” rather than a subtle nondeterminism problem.
|
| 134 |
+
|
| 135 |
+
When the logs are **ambiguous**—neither clearly infra-broken nor clearly a stable deterministic failure—**repeated runs are the fallback**: more samples to estimate **pass rate**, **failure entropy**, and whether the behavior looks **stable, flaky, or deterministic** before we let policy learning spend credit on a patch. That is the role of the **preflight** gate in the environment: it separates *trainable flaky-like* scenarios from *skip this example*.
|
| 136 |
+
|
| 137 |
+
### The second non-negotiable: never let the model corrupt the program state
|
| 138 |
+
|
| 139 |
+
Even a perfect “think” block is useless if the next line is a **patch that does not apply** or, worse, **breaks `ast.parse`**. If we wrote broken Python to disk, the **entire** interaction shifts: the next observation is dominated by **syntax and parse failures**, and the policy is no longer learning “how to stabilize a flaky test”—it is learning how to **recover from the agent’s own damage**.
|
| 140 |
+
|
| 141 |
+
That is why we introduced a **`PatchValidator`**: a **code-side** gate that runs **before** durable writes. The validator answers a different question from the oracle: not “is the hypothesis true?” but “**is this patch well-formed, simulatable, and safe to apply?**”
|
| 142 |
+
|
| 143 |
+
### `PatchValidator` in detail (stages, from `server/patch_validator.py`)
|
| 144 |
+
|
| 145 |
+
The module docstring encodes a fixed pipeline. **Invalid patches are rejected before the repo on disk is mutated.** That single rule is what keeps the training problem stable: the environment never asks pytest to arbitrate a patch that is not even a legal edit of the repository.
|
| 146 |
+
|
| 147 |
+
**format** is the first gate. The model must produce **SEARCH/REPLACE** hunks with the expected delimiters. Empty patches or missing markers are rejected immediately; there is no point simulating or compiling text that is not a patch at all.
|
| 148 |
+
|
| 149 |
+
**anti-hack** runs next and treats a short list of patterns as **hard errors** before any simulation. The validator scans the hunk text for known reward hacks: for example deleting more assertions than it adds, injecting `time.sleep` or `asyncio.sleep`, adding `@pytest.mark.skip` or `pytest.skip`, or swallowing exceptions with `except: pass`. These checks are cheap to run, and they exist so a policy cannot “win” by hollowing out what the test is supposed to assert.
|
| 150 |
+
|
| 151 |
+
**apply simulation** still does **not** write to disk. The patch is applied in memory against a snapshot of sources via `simulate_search_replace_patch`. Every search block must match real file content. The implementation can optionally relax indentation in a controlled way; when that path fires, it is surfaced as a **warning** rather than silently accepted. If simulation fails, the patch is **invalid**: there is no coherent edit left to evaluate under pytest.
|
| 152 |
+
|
| 153 |
+
**reasoning–action bridge** applies when structured claims name a function or file. After simulation, the validator checks that the patch **actually edits** that locus. A common failure mode is purely rhetorical: the think block points at an entity, but the AST text for that node is unchanged—“you said you fixed X, but X is identical.” That mismatch is caught here.
|
| 154 |
+
|
| 155 |
+
**Smell checks on added lines** look at what the patch **introduces**, not only whether it applies. New uses of `random.random`, `datetime.now` at module level, new mutable global assignments, or new `lru_cache` are examples of patterns the policy should not learn to lean on. The code distinguishes warnings from hard failures along several paths; the intent is to flag flakiness the training objective is meant to discourage.
|
| 156 |
+
|
| 157 |
+
**Size and idempotency** limit how much the model can rewrite in one step. Diffs that exceed a line-change budget are rejected so the agent cannot harvest reward by replacing large swaths of the tree. A **second** simulated apply checks **idempotency**: a correct search/replace should not produce a different outcome if applied again to the already-patched text.
|
| 158 |
+
|
| 159 |
+
**syntax, compile, and structure** run **per modified** `.py` file. Post-patch source must pass `ast.parse` and then `compile`. Structural heuristics flag obviously broken trees, such as empty function or class bodies. Optional LibCST roundtrip checks add another consistency layer. Undefined-name heuristics catch patches that mention names like `threading` or `Lock` without bringing them into scope via imports.
|
| 160 |
+
|
| 161 |
+
**causal proximity** is softer: it issues **warnings**, not automatic rejections. If hunks touch files far from the failure frontier and the causal call chain, the validator mirrors the signal used in **causal_proximity_reward**. The edit may still be valid, but the hint is that it might be a workaround rather than a localized fix.
|
| 162 |
+
|
| 163 |
+
**Output** packages everything for the rest of the environment: a boolean **`is_valid`**, lists of **errors** and **warnings**, and a **score in \([0,1]\)** used for reward shaping when the patch is valid. That score can penalize noop patches, fuzzy apply, or very large diffs even when the patch technically applies. Taken together, these stages tell one story: **preflight on the patch keeps the workspace healthy before pytest is allowed to speak.**
|
| 164 |
+
|
| 165 |
+
### `OracleEngine` in detail: verifying “think” against code (`server/oracle_engine.py`)
|
| 166 |
+
|
| 167 |
+
The **oracle** is the complement of the **validator**. The validator answers whether the patch is a legal, safe edit of the codebase. The oracle, through **`verify_structured_think`**, answers a different question: **given pre- and post-patch source, is each `ThinkClaim` actually supported by the code, and does the patch meaningfully address that claim?**
|
| 168 |
+
|
| 169 |
+
**Per-category plugins** implement that question for each kind of root cause. Each `ROOT_CAUSE_TYPES` value can register an **`OraclePlugin`** in a registry. The codebase includes examples for race and async-wait behavior, LRU cache issues, mock leaks, shared state, fixture scope, network boundaries, import side effects, and more. For a given category, the plugin uses the AST (and LibCST when available) to test static conditions that match that failure mode—evidence of a lock, a suspicious decorator, a boundary pattern, and similar. The oracle is not improvising; it is running reproducible checks tied to the claim type.
|
| 170 |
+
|
| 171 |
+
**Evidence** is recorded per claim in an **`OracleEvidence`** object. That structure captures whether the named entity resolved in the file, whether expected pre- and post-conditions held, and whether the patch plausibly addresses what the claim says went wrong. The oracle thus builds an auditable record rather than a single opaque score.
|
| 172 |
+
|
| 173 |
+
**Patch coherence** is enforced by a **`PatchCoherenceOracle`**. Even when a category plugin would like to say the claim is **confirmed**, the cross-check asks whether the **patch** and the **claim** actually fit together. If they do not cohere, the verdict is **downgraded** toward **inconclusive** so that “sounding right” cannot outrun “editing consistently.”
|
| 174 |
+
|
| 175 |
+
**Scoring** closes the loop. **`verify_structured_think`** returns a **`StructuredThink`** with a per-claim **`verdict`** and **`oracle_score`**, plus an aggregate **`oracle_score`** in \([-1,1]\) that **`compute_verifiable_reward`** consumes. The important distinction is philosophical as well as technical: **the oracle is not a chat model.** It is static analysis plus alignment between hunks and claims—the cheap, scalable side of process supervision.
|
| 176 |
+
|
| 177 |
+
**Why both validator and oracle** matters for training design. Preflight and logs keep episodes in the **right problem class** (flaky-like vs broken checkout). The **patch validator** ensures the workspace is never corrupted by an illegal or obviously hacked edit, so the policy does not collapse into learning **syntax repair** for its own mistakes. The **oracle** operates **after** the world is still intact: it scores whether the **stated** diagnosis matches **what the code and patch actually do**, so the thinking channel cannot float free of the editing channel. In one line: **the validator guards the repo; the oracle guards the honesty of the reasoning.**
|
| 178 |
+
|
| 179 |
+
---
|
| 180 |
+
|
| 181 |
+
## 6. System Architecture
|
| 182 |
+
|
| 183 |
+
FlakeForge is an OpenEnv-style environment served through FastAPI. The environment exposes the usual reset/step interaction, but the internals are specific to flaky-test repair.
|
| 184 |
+
|
| 185 |
+
```mermaid
|
| 186 |
+
flowchart TD
|
| 187 |
+
A[Repository + Target Test] --> B[Preflight Gate]
|
| 188 |
+
B --> C[Observation Builder]
|
| 189 |
+
C --> D[Unified Agent]
|
| 190 |
+
D --> E[Structured Think + Patch]
|
| 191 |
+
E --> F[Patch Validator]
|
| 192 |
+
F --> G[Apply Patch Atomically]
|
| 193 |
+
G --> H[Repeated Pytest Runs]
|
| 194 |
+
H --> I[Reward Function]
|
| 195 |
+
I --> J[Next Observation]
|
| 196 |
+
J --> D
|
| 197 |
+
```
|
| 198 |
+
|
| 199 |
+
Architecturally, the pieces chain together in the order an engineer would expect: **`FlakeForgeEnvironment`** owns the episode. It snapshots Python sources, restores a pristine tree when needed, applies validated patches, runs **`pytest`** in a loop, and hands back a **`FlakeForgeObservation`**. Sitting in front of the model, **`UnifiedFlakeForgeAgent`** is only the interface that turns a completion into one JSON object with **`think`** claims and **`patch`** hunks. Between raw model text and disk, the **patch validator** enforces that nothing malformed or obviously hacked reaches the repo. After the run, **`compute_verifiable_reward`** folds pass-rate movement, apply and compile health, how close the edit sits to the failure frontier, anti-hack signals, and reasoning consistency into a single training signal. Around that core, the **IDoFT builder** supplies curated real cases (labels, root-cause files, difficulty, PR links), and the **training pipeline** uses a warm-up phase for valid JSON and coherent claims before **GRPO** rolls out against the live environment.
|
| 200 |
+
|
| 201 |
+
---
|
| 202 |
+
|
| 203 |
+
## 7. Environment Design for RL
|
| 204 |
+
|
| 205 |
+
FlakeForge is a partially observable Markov decision process on paper, but the lived shape of an episode is a **tight loop**: the world holds far more than the policy ever sees, the policy speaks once per step in a rigid schema, the environment mutates and measures, and the next observation tells the story of what actually happened.
|
| 206 |
+
|
| 207 |
+
### State
|
| 208 |
+
|
| 209 |
+
The **full state** is everything the harness knows and the agent does not. It spans the **repository contents** and the **target test** identity, the **pristine snapshot** of files that makes `reset` trustworthy, and bookkeeping such as **step count**, **max steps**, **baseline vs current pass rate**, the **patch and reward history** accumulated in the episode, and the **distribution of failures** across repeated runs. None of that needs to fit in context; it is the ground truth the environment integrates over. Between episodes, the environment **restores** that pristine snapshot so parallel GRPO rollouts never inherit a half-patched tree—the next episode always starts from the same clean contract with the codebase.
|
| 210 |
+
|
| 211 |
+
### Observation
|
| 212 |
+
|
| 213 |
+
What the agent **does** see is a deliberate compression of that state into a **`FlakeForgeObservation`**. It is not a single stack trace. The builder threads in **test source** and **code under test**, a **file tree** and **relevant imports**, and a **run history** that already encodes repetition: pass or fail, how long the run took, error type, and stderr excerpts. Preflight and metrics surface **baseline and current pass rates** and a **classification** of whether the scenario looks stable, flaky, like a deterministic bug, infra-broken, or still ambiguous. Static passes contribute **deep flakiness** hints; stack analysis yields a **failure frontier** and **causal** summaries that point toward boundaries in the call graph. The previous step’s **reward breakdown** and the **think/patch history** ride along so the model is not asked to debug in a memory vacuum. The design bet is simple: flaky behavior often **hides** behind one unlucky run, so the observation is built to show **patterns across runs**, not a single screenshot of failure.
|
| 214 |
+
|
| 215 |
+
### Action
|
| 216 |
+
|
| 217 |
+
Each step, the policy emits **one** structured action, a **`FlakeForgeAction`** serialized as JSON. The **`think`** half names **claims**: category, entity, location (for example `path::function`), polarity, and a short reason. The **`patch`** half lists **search/replace** hunks bound to real paths. That pairing is the whole point—the environment can ask whether the **words** and the **diff** agree before pytest ever runs.
|
| 218 |
+
|
| 219 |
+
```json
|
| 220 |
+
{
|
| 221 |
+
"think": {
|
| 222 |
+
"claims": [
|
| 223 |
+
{
|
| 224 |
+
"category": "concurrency",
|
| 225 |
+
"entity": "counter",
|
| 226 |
+
"location": "source.py::increment",
|
| 227 |
+
"polarity": "present",
|
| 228 |
+
"reason": "shared counter is updated without synchronization"
|
| 229 |
+
}
|
| 230 |
+
],
|
| 231 |
+
"confidence": 0.87
|
| 232 |
+
},
|
| 233 |
+
"patch": {
|
| 234 |
+
"hunks": [
|
| 235 |
+
{
|
| 236 |
+
"file": "source.py",
|
| 237 |
+
"search": "counter = counter + 1",
|
| 238 |
+
"replace": "with _lock:\n counter = counter + 1"
|
| 239 |
+
}
|
| 240 |
+
]
|
| 241 |
+
}
|
| 242 |
+
}
|
| 243 |
+
```
|
| 244 |
+
|
| 245 |
+
Because both halves live in one object, “I believe X is wrong” and “here is the edit for X” are **forced** to be the same move; reward and oracle machinery can treat inconsistency as a first-class mistake rather than a post-hoc judgment.
|
| 246 |
+
|
| 247 |
+
### Transitions
|
| 248 |
+
|
| 249 |
+
A **`step`** walks that move from intent to evidence. The harness **parses** the model output into structured think and patch objects, then runs **validation**: format, anti-hack checks, simulation, and the syntax/compile passes described earlier. Only if the patch survives does the environment **apply** it atomically and **re-run** the target test the configured number of times. From those runs it updates **pass rate**, **failure entropy**, and related statistics, folds everything into **`compute_verifiable_reward`**, and finally **returns** the next observation with refreshed history. The transition is therefore not “append chat”; it is **measure whether execution changed**. That is why RL here is meaningful: fluent diagnosis without a behavioral delta burns budget without earning the central part of the reward.
|
| 250 |
+
|
| 251 |
+
### Reward
|
| 252 |
+
|
| 253 |
+
**`compute_verifiable_reward`** is deliberately **multi-signal** so no single loophole dominates. **Format** credit rewards a well-formed action schema. **Compile and apply** terms align with the validator: the tree must remain legal Python and the edit must be real. **Stability**—movement in **repeated pass rate**—sits at the center, because fixing a flaky test means changing outcomes across runs, not winning once. **Causal proximity** nudges edits toward the failure frontier and call chain; **failure entropy** rewards calmer, less chaotic failure modes when things are still red. **Anti-hack** and **regression** terms punish skips, gutted assertions, sleeps, swallowed exceptions, and collateral damage. **Reasoning consistency** ties the oracle’s view of claims and patches into the scalar signal, and a **terminal bonus** marks when the target test reaches **stable** repeated passes. Pass-rate improvement remains the spine of the objective, but the flanking terms exist so the policy cannot trade the whole problem for **pretty JSON** or **syntax repair**—it has to earn its green runs without hacking the meaning of the test.
|
| 254 |
+
|
| 255 |
+
---
|
| 256 |
+
|
| 257 |
+
## 8. Deep Flakiness Signals
|
| 258 |
+
|
| 259 |
+
FlakeForge adds static signals because the agent should not rediscover every common flaky pattern from scratch.
|
| 260 |
+
|
| 261 |
+
The deep scanner looks for patterns such as:
|
| 262 |
+
|
| 263 |
+
- Module-level caches and mutable globals.
|
| 264 |
+
- Broad fixture scopes with mutable state.
|
| 265 |
+
- Mock patches that may leak.
|
| 266 |
+
- Import-time side effects.
|
| 267 |
+
- Async or thread contamination.
|
| 268 |
+
- Resource leaks involving files, sockets, subprocesses, or threads.
|
| 269 |
+
|
| 270 |
+
These signals are not treated as final truth. They are hints. The reward still depends on whether the patch improves repeated execution.
|
| 271 |
+
|
| 272 |
+
This combination is important: static signals make exploration less blind, while runtime validation prevents static warnings from becoming false confidence.
|
| 273 |
+
|
| 274 |
+
---
|
| 275 |
+
|
| 276 |
+
## 9. Dataset Construction
|
| 277 |
+
|
| 278 |
+
For realistic training cases, FlakeForge uses a curated IDoFT-based dataset builder.
|
| 279 |
+
|
| 280 |
+
The builder was designed with several constraints:
|
| 281 |
+
|
| 282 |
+
- One unique repository and test pair per entry.
|
| 283 |
+
- Coverage across IDoFT categories such as order dependency and infrastructure dependency.
|
| 284 |
+
- Coverage across FlakeForge categories such as resource leak, shared state, ordering, timing, network, and nondeterminism.
|
| 285 |
+
- Difficulty split across easy, medium, and hard cases.
|
| 286 |
+
- Preference for cases with accepted or opened PRs, because real fixes provide useful supervision.
|
| 287 |
+
- Preference for runnable and maintained repositories when possible.
|
| 288 |
+
|
| 289 |
+
The target was not just to collect flaky tests. The goal was to build a curriculum where early cases teach simple localization and later cases force the agent to reason across timing, hidden state, and repository complexity.
|
| 290 |
+
|
| 291 |
+
Synthetic test repositories are also useful because they isolate specific root causes: time-based nondeterminism, async leaks, fixture scope issues, shared state, cache pollution, platform dependency, network instability, and resource leaks.
|
| 292 |
+
|
| 293 |
+
Real cases provide messiness. Synthetic cases provide control. FlakeForge needs both.
|
| 294 |
+
|
| 295 |
+
---
|
| 296 |
+
|
| 297 |
+
## 10. Training Pipeline
|
| 298 |
+
|
| 299 |
+
The training setup uses a two-phase approach.
|
| 300 |
+
|
| 301 |
+
### Phase 1: Warm-Up
|
| 302 |
+
|
| 303 |
+
The warm-up phase teaches the model to emit valid FlakeForge actions before it interacts with real environments.
|
| 304 |
+
|
| 305 |
+
This phase uses offline rewards for:
|
| 306 |
+
|
| 307 |
+
- JSON format correctness.
|
| 308 |
+
- Valid root-cause categories.
|
| 309 |
+
- Structured claim fields.
|
| 310 |
+
- Patch hunk fields.
|
| 311 |
+
- Basic reasoning consistency.
|
| 312 |
+
|
| 313 |
+
This is cheap and fast. It prevents online RL from wasting most of its budget on malformed output.
|
| 314 |
+
|
| 315 |
+
### Phase 2: Online GRPO
|
| 316 |
+
|
| 317 |
+
The online phase runs the actual environment.
|
| 318 |
+
|
| 319 |
+
For each curriculum case:
|
| 320 |
+
|
| 321 |
+
1. The model receives an observation prompt.
|
| 322 |
+
2. It generates a group of candidate completions.
|
| 323 |
+
3. Each candidate is applied on a clean repository copy.
|
| 324 |
+
4. The environment reruns tests and computes reward.
|
| 325 |
+
5. GRPO updates the model using relative performance within the group.
|
| 326 |
+
|
| 327 |
+
The default training path targets Qwen2.5-Coder models, with QLoRA/Unsloth support for efficient fine-tuning. The code supports warm-up only, online only, or both phases.
|
| 328 |
+
|
| 329 |
+
This setup is useful because flaky-test repair has sparse success signals. Many attempts do not fully fix the test. GRPO lets the model learn from relative differences: which candidate was more structured, more applicable, closer to the failure site, less hacky, or more stabilizing.
|
| 330 |
+
|
| 331 |
+
---
|
| 332 |
+
|
| 333 |
+
## 11. Results and Early Insights
|
| 334 |
+
|
| 335 |
+
The most important early result is not a single leaderboard number. It is that the environment can distinguish cases that should and should not be used for flaky-fix training.
|
| 336 |
+
|
| 337 |
+
During probing, many candidate IDoFT cases fell into different buckets:
|
| 338 |
+
|
| 339 |
+
- Some tests were already stable and should not be used for repair training.
|
| 340 |
+
- Some were deterministic failures, not flaky failures.
|
| 341 |
+
- Some were infrastructure-broken because dependencies, paths, collectors, or imports did not work in the local checkout.
|
| 342 |
+
- Some exposed the real target behavior and were suitable for training.
|
| 343 |
+
|
| 344 |
+
That filtering matters. If a training loop treats stable tests, broken repos, deterministic bugs, and true flakes as the same task, the reward becomes noisy and misleading.
|
| 345 |
+
|
| 346 |
+
The strongest insight so far is that flaky detection is itself a hard problem. Before an agent can learn to fix flaky tests, the environment must prove that the scenario is actually flaky and runnable.
|
| 347 |
+
|
| 348 |
+
Another insight: the structure of the action matters. Asking the model for a patch alone loses useful information. Asking for a diagnosis alone does not change the program. Combining `think` and `patch` gives the environment something it can validate from both sides.
|
| 349 |
+
|
| 350 |
+
---
|
| 351 |
+
|
| 352 |
+
## 12. What Makes FlakeForge Different
|
| 353 |
+
|
| 354 |
+
FlakeForge is not just a flaky-test detector and not just an LLM patch generator.
|
| 355 |
+
|
| 356 |
+
It is an environment where:
|
| 357 |
+
|
| 358 |
+
- The agent learns through interaction.
|
| 359 |
+
- The reward is grounded in repeated test execution.
|
| 360 |
+
- Static and causal signals guide exploration.
|
| 361 |
+
- Patch validation blocks obvious bad edits.
|
| 362 |
+
- The model must connect root cause to code change.
|
| 363 |
+
- Training cases are organized as a curriculum.
|
| 364 |
+
|
| 365 |
+
The central bet is simple: software repair agents should be trained inside environments that look like software engineering, not just text prediction.
|
| 366 |
+
|
| 367 |
+
---
|
| 368 |
+
|
| 369 |
+
## 13. Challenges We Faced
|
| 370 |
+
|
| 371 |
+
*For the full design arc—two-head analyzer/fixer, the judge, and why we moved to a unified, environment-grounded policy—see [§3. How we got here](#3-how-we-got-here-two-heads-a-judge-and-the-unified-bet). For preflight (flaky vs buggy), logs vs pass rate, and the **patch validator** and **oracle** in depth, see [§5. The middle of the build](#5-the-middle-of-the-build-flaky-vs-buggy-two-part-reward-and-why-validation-is-first).*
|
| 372 |
+
|
| 373 |
+
### 1. Detecting Flakiness Was Harder Than Fixing It
|
| 374 |
+
|
| 375 |
+
The first challenge was separating true flaky tests from stable tests, deterministic failures, and broken infrastructure. A test that fails every time is not a flaky-test repair target. A test that cannot import its dependencies is not a learning signal. A test that passes ten times in a row may not be useful for training, even if it was historically flaky.
|
| 376 |
+
|
| 377 |
+
This forced FlakeForge to add a preflight gate with sanity checks, deterministic checks, and flakiness confirmation runs.
|
| 378 |
+
|
| 379 |
+
### 2. Real Repositories Are Messy
|
| 380 |
+
|
| 381 |
+
IDoFT-style real-world cases are valuable, but they come with practical issues:
|
| 382 |
+
|
| 383 |
+
- Missing dependencies.
|
| 384 |
+
- Old package versions.
|
| 385 |
+
- Broken test collectors.
|
| 386 |
+
- Path assumptions.
|
| 387 |
+
- Platform-specific behavior.
|
| 388 |
+
- Repositories that no longer install cleanly.
|
| 389 |
+
- Tests whose original flaky behavior depends on a historical CI environment.
|
| 390 |
+
|
| 391 |
+
This made dataset construction more than a scraping task. Each case needed metadata, difficulty labels, root-cause hints, and runnability checks.
|
| 392 |
+
|
| 393 |
+
### 3. Patch Validation Needed to Be Strict
|
| 394 |
+
|
| 395 |
+
LLMs often produce patches that look reasonable but fail mechanically:
|
| 396 |
+
|
| 397 |
+
- Search text does not match the file.
|
| 398 |
+
- Indentation is wrong.
|
| 399 |
+
- Multi-line replacements break JSON.
|
| 400 |
+
- The patch edits the test instead of the source.
|
| 401 |
+
- The code no longer parses.
|
| 402 |
+
|
| 403 |
+
Without strict validation, the environment would reward noise or corrupt the repository. FlakeForge therefore validates patches before applying them and restores clean snapshots between episodes.
|
| 404 |
+
|
| 405 |
+
### 4. Reward Hacking Was a Real Risk
|
| 406 |
+
|
| 407 |
+
Any RL environment can be gamed. In flaky-test repair, obvious hacks include:
|
| 408 |
+
|
| 409 |
+
- Adding `sleep()`.
|
| 410 |
+
- Skipping the test.
|
| 411 |
+
- Weakening assertions.
|
| 412 |
+
- Catching broad exceptions.
|
| 413 |
+
- Returning early.
|
| 414 |
+
- Deleting meaningful checks.
|
| 415 |
+
|
| 416 |
+
Those edits can make a test pass while destroying its value. The reward function had to include anti-hack penalties and hard gates so the model learns structural fixes instead of CI tricks.
|
| 417 |
+
|
| 418 |
+
### 5. One Run Is Not Evidence
|
| 419 |
+
|
| 420 |
+
A normal unit-test repair loop often runs a test once. Flaky tests require repeated runs. But repeated execution is expensive, especially inside online RL.
|
| 421 |
+
|
| 422 |
+
FlakeForge had to balance cost and confidence: enough runs to estimate pass-rate movement, but not so many that training becomes impossible.
|
| 423 |
+
|
| 424 |
+
### 6. Reasoning Needed Grounding
|
| 425 |
+
|
| 426 |
+
The model can say "this is a race condition" even when the patch edits an unrelated line. It can claim "resource leak" while changing a timeout. That is why FlakeForge tracks reasoning consistency and uses structured claims tied to locations and patch hunks.
|
| 427 |
+
|
| 428 |
+
The goal is not to reward chain-of-thought style prose. The goal is to reward diagnosis that is checkable against code and outcomes.
|
| 429 |
+
|
| 430 |
+
### 7. Infrastructure Became Part of the Environment
|
| 431 |
+
|
| 432 |
+
Running arbitrary repositories means the runner, Docker setup, dependencies, environment variables, and filesystem layout all become part of the task. A broken runner can look like a broken test. This made infrastructure classification essential.
|
| 433 |
+
|
| 434 |
+
The environment must know when to say: "Do not train on this case."
|
| 435 |
+
|
| 436 |
+
### 8. Multi-Role and Judge-Model Detours (Design, Not Data)
|
| 437 |
+
|
| 438 |
+
Before we committed to a **single** unified action, we tried **separate** analyzer and fixer models and then a **judge** to critique them. The problems were not cosmetic: hand-offs split responsibility, **three** models made inference slow and expensive, and an inference-only judge **diluted** the training signal because the two workers were only steered by a critic that was **not** itself grounded in the same verifiable loop. That is why the shipped design folds diagnosis and patch into one pass and lets **`pytest` + static checks** carry the weight that we once tried to delegate to another LLM—see [§3](#3-how-we-got-here-two-heads-a-judge-and-the-unified-bet).
|
| 439 |
+
|
| 440 |
+
---
|
| 441 |
+
|
| 442 |
+
## 14. Lessons Learned
|
| 443 |
+
|
| 444 |
+
Flaky-test repair is less about finding one failed assertion and more about stabilizing a system.
|
| 445 |
+
|
| 446 |
+
The biggest lesson is that a good RL environment needs strong boundaries. It must define what counts as a valid action, what counts as evidence, what counts as success, and what behavior is forbidden.
|
| 447 |
+
|
| 448 |
+
For FlakeForge, that meant:
|
| 449 |
+
|
| 450 |
+
- Treat repeated execution as the primary truth.
|
| 451 |
+
- Use static analysis as hints, not final judgment.
|
| 452 |
+
- Make the action format structured and machine-checkable.
|
| 453 |
+
- Reject invalid patches early.
|
| 454 |
+
- Penalize shortcuts that reduce test meaning.
|
| 455 |
+
- Separate runnable flaky cases from stable, deterministic, or broken ones.
|
| 456 |
+
|
| 457 |
+
The second lesson is that curriculum matters. A model cannot jump directly into messy real repositories and learn efficiently if half the failures are infrastructure problems. Synthetic cases teach clean root-cause patterns; IDoFT cases test whether those skills survive contact with real projects.
|
| 458 |
+
|
| 459 |
+
The third lesson is that verifiable rewards are worth the engineering cost. They are slower than asking an LLM judge, but they are aligned with the thing developers actually care about: a test suite that stays green for the right reason.
|
| 460 |
+
|
| 461 |
+
---
|
| 462 |
+
|
| 463 |
+
## 15. Closing
|
| 464 |
+
|
| 465 |
+
FlakeForge is an attempt to make flaky-test repair trainable, measurable, and honest.
|
| 466 |
+
|
| 467 |
+
Instead of asking a model to guess a fix from a stack trace, it places the model inside a debugging environment. The agent observes repeated failures, forms a structured hypothesis, patches the code, and receives reward from actual execution.
|
| 468 |
+
|
| 469 |
+
That makes the environment harder to build, but also more meaningful. Flaky tests are caused by runtime behavior, so the learning signal should come from runtime behavior.
|
| 470 |
+
|
| 471 |
+
The long-term vision is an agent that does not merely silence flaky tests, but repairs the underlying instability with small, verifiable patches.
|
| 472 |
+
|
README.md
CHANGED
|
@@ -1,10 +1,836 @@
|
|
| 1 |
-
---
|
| 2 |
-
title:
|
| 3 |
-
emoji:
|
| 4 |
-
colorFrom:
|
| 5 |
-
colorTo:
|
| 6 |
-
sdk: docker
|
| 7 |
-
pinned: false
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
title: Flakeforge Environment Server
|
| 3 |
+
emoji: 🔕
|
| 4 |
+
colorFrom: pink
|
| 5 |
+
colorTo: green
|
| 6 |
+
sdk: docker
|
| 7 |
+
pinned: false
|
| 8 |
+
app_port: 8000
|
| 9 |
+
base_path: /
|
| 10 |
+
tags:
|
| 11 |
+
- openenv
|
| 12 |
+
---
|
| 13 |
+
|
| 14 |
+
# FlakeForge
|
| 15 |
+
|
| 16 |
+
[](https://huggingface.co/docs/hub/spaces)
|
| 17 |
+
[](https://www.python.org/)
|
| 18 |
+
[](https://huggingface.co/)
|
| 19 |
+
[](https://pytorch.org/)
|
| 20 |
+
[](https://fastapi.tiangolo.com/)
|
| 21 |
+
[](https://www.docker.com/)
|
| 22 |
+
[](https://docs.pydantic.dev/)
|
| 23 |
+
[](https://www.uvicorn.org/)
|
| 24 |
+
|
| 25 |
+
**FlakeForge** is an [OpenEnv](https://pypi.org/project/openenv-core/)–style reinforcement learning **environment** for learning to **repair flaky Python tests**. The world is (mostly) deterministic: repeated `pytest` runs, AST scans, and static checks produce structured observations. The only learned piece is the policy (your code model); reward is **verifiable** from execution and static oracles, not an LLM judge.
|
| 26 |
+
|
| 27 |
+
---
|
| 28 |
+
|
| 29 |
+
## Table of contents
|
| 30 |
+
|
| 31 |
+
1. [Intuition and inspiration](#1-intuition-and-inspiration)
|
| 32 |
+
- [Flaky test root causes (visual taxonomy)](#flaky-test-root-causes-visual-taxonomy)
|
| 33 |
+
2. [Problem we solve](#2-problem-we-solve)
|
| 34 |
+
3. [Environment interface (POMDP view)](#3-environment-interface-pomdp-view)
|
| 35 |
+
4. [Observation space](#4-observation-space)
|
| 36 |
+
5. [Action space (unified agent)](#5-action-space-unified-agent)
|
| 37 |
+
6. [Root-cause categories](#6-root-cause-categories)
|
| 38 |
+
7. [Unified agent: what it outputs and what it sees](#7-unified-agent-what-it-outputs-and-what-it-sees)
|
| 39 |
+
8. [Core tooling](#8-core-tooling)
|
| 40 |
+
- [Causal graph engine](#causal-graph-engine)
|
| 41 |
+
- [Deep flakiness scanners](#deep-flakiness-scanners)
|
| 42 |
+
9. [Verification stack: oracle engine and patch validator](#9-verification-stack-oracle-engine-and-patch-validator)
|
| 43 |
+
10. [Verifiable reward system](#10-verifiable-reward-system)
|
| 44 |
+
11. [OpenEnv config and commands](#11-openenv-config-and-commands)
|
| 45 |
+
12. [Build, run, and test locally](#12-build-run-and-test-locally)
|
| 46 |
+
- [Inference log (`outputs/inference.log`)](#inference-log-outputsinferencelog)
|
| 47 |
+
13. [Deploy on Hugging Face Spaces](#13-deploy-on-hugging-face-spaces)
|
| 48 |
+
14. [RL training (Unsloth, curriculum)](#14-rl-training-unsloth-curriculum)
|
| 49 |
+
15. [Repository layout](#15-repository-layout)
|
| 50 |
+
16. [Contributing](#16-contributing)
|
| 51 |
+
|
| 52 |
+
---
|
| 53 |
+
|
| 54 |
+
## 1. Intuition and inspiration
|
| 55 |
+
|
| 56 |
+
Flaky tests are a major source of **wasted CI time** and **wrong signals** for both humans and ML. Classic fixes (bigger timeouts, more retries, blanket skips) often **mask** symptoms while hiding deeper bugs. We wanted a setting where an agent is pushed toward **structural, minimal fixes** that **survive repeated validation**—closer to how a senior engineer reasons (hypothesis → small edit → re-run) than to a one-shot “rewrite the file” code completion task.
|
| 57 |
+
|
| 58 |
+
**Inspiration** comes from three lines of work: (1) **POMDP / RL** for sequential decision making under partial observability, (2) **software engineering** research on flaky-test patterns (concurrency, order, I/O, shared state, fixtures, mocks, imports), and (3) **verifiable** training signals: what you can *measure* in pytest output and in the repo’s AST beats what you can only *opine* with another LLM.
|
| 59 |
+
|
| 60 |
+
### Flaky test root causes (visual taxonomy)
|
| 61 |
+
|
| 62 |
+
A **flaky test** is one that **sometimes passes and sometimes fails** for the **same version** of the code. The *symptom* is always “CI is noisy”; the *cause* is usually **hidden shared state**, **timing**, or **environment** coupling. FlakeForge labels hypotheses with the **`ROOT_CAUSE_TYPES`** enum in `models.py` (used in structured `think` claims). Below is how those labels relate to each other and what they mean in practice.
|
| 63 |
+
|
| 64 |
+
**Map — how the official categories cluster** (read the table after the diagrams for details):
|
| 65 |
+
|
| 66 |
+
```mermaid
|
| 67 |
+
mindmap
|
| 68 |
+
root((ROOT_CAUSE_TYPES))
|
| 69 |
+
Timing and races
|
| 70 |
+
async_wait
|
| 71 |
+
concurrency
|
| 72 |
+
nondeterminism
|
| 73 |
+
Shared world and order
|
| 74 |
+
shared_state
|
| 75 |
+
test_order_dependency
|
| 76 |
+
module_cache_pollution
|
| 77 |
+
Imports and module lifecycle
|
| 78 |
+
import_side_effect
|
| 79 |
+
Harness and doubles
|
| 80 |
+
fixture_scope_leak
|
| 81 |
+
mock_residue
|
| 82 |
+
External and resources
|
| 83 |
+
network
|
| 84 |
+
platform_dependency
|
| 85 |
+
resource_leak
|
| 86 |
+
Fallback
|
| 87 |
+
unknown
|
| 88 |
+
```
|
| 89 |
+
|
| 90 |
+
**Conceptual chain** (how families interact—not separate bugs, but **layers** that stack): **timing races** expose **shared mutable state**; **imports and caches** make that state global; **fixtures and mocks** can spread or hide it; **network/platform** and **leaks** turn small races into **order-dependent** failures. Use the table below for precise definitions.
|
| 91 |
+
|
| 92 |
+
**Lifecycle view — where nondeterminism sneaks in** (collection → run → teardown):
|
| 93 |
+
|
| 94 |
+
```mermaid
|
| 95 |
+
sequenceDiagram
|
| 96 |
+
participant C as Test collection / import
|
| 97 |
+
participant R as Run body
|
| 98 |
+
participant T as Teardown / next test
|
| 99 |
+
C->>R: import_side_effect, module_cache_pollution
|
| 100 |
+
R->>R: async_wait, concurrency, nondeterminism, network
|
| 101 |
+
R->>T: fixture_scope_leak, mock_residue, resource_leak
|
| 102 |
+
T->>C: shared_state, test_order_dependency
|
| 103 |
+
```
|
| 104 |
+
|
| 105 |
+
| Category | What typically goes wrong | Why the outcome flickers | Fix direction (high level) |
|
| 106 |
+
|----------|-----------------------------|----------------------------|----------------------------|
|
| 107 |
+
| **`async_wait`** | `async`/`await` misuse, wrong timeout, background tasks not awaited | Event-loop scheduling and I/O completion order change between runs | Correct `await`, bounded waits, deterministic async teardown |
|
| 108 |
+
| **`concurrency`** | Threads, locks, shared mutable state without proper synchronization | Race windows open/close depending on CPU scheduling | Narrow critical sections, proper locks/events, avoid data races |
|
| 109 |
+
| **`test_order_dependency`** | Test A leaves state that test B reads; suite order varies | Different collection or parallel workers → different interleaving | Isolate tests, reset globals, avoid cross-test dependencies |
|
| 110 |
+
| **`resource_leak`** | Files, sockets, threads, subprocesses not closed | Later tests hit **FD limits**, port exhaustion, or zombie processes | Deterministic `close()` / context managers, pool limits in tests |
|
| 111 |
+
| **`shared_state`** | Globals, class attributes, singletons, mutable defaults | Leftover mutations change assertions on the next run | Reset in fixtures, copy-on-write test data, no module-level mutation |
|
| 112 |
+
| **`network`** | Real HTTP/DB without hermetic doubles | Latency spikes, rate limits, remote flakiness | Mocks, recorded responses, local fakes, retry policy in **SUT** not tests |
|
| 113 |
+
| **`platform_dependency`** | Paths, clocks, timezones, OS-specific APIs | CI image vs laptop differs; wall-clock assumptions | `pathlib`, inject clock, freeze time in tests, portable APIs |
|
| 114 |
+
| **`nondeterminism`** | Unseeded randomness, iteration over unordered sets, wall-clock timing in logic | Different values or ordering each run | Seed RNG, sort for stability, replace “sleep for sync” with signals |
|
| 115 |
+
| **`import_side_effect`** | Module body runs DB/network/setup on import | Import order or first-import timing changes global state | Move side effects behind `main()` or explicit init functions |
|
| 116 |
+
| **`module_cache_pollution`** | `sys.modules` / `@lru_cache` / process-wide caches retain stale data | “First import wins”; order defines cache contents | Clear caches in teardown, avoid process-wide caches in tests, reload strategy |
|
| 117 |
+
| **`fixture_scope_leak`** | `session`/`module`-scoped fixtures mutate shared resources | Wide scope + mutation leaks across tests | Narrow fixture scope, factory fixtures, explicit reset fixtures |
|
| 118 |
+
| **`mock_residue`** | `patch`/`mock` not stopped or leaks into imports | Later tests see patched symbols or half-mocked deps | `with patch(...)`, `addCleanup`, pytest plugins that enforce cleanup |
|
| 119 |
+
| **`unknown`** | Signal is weak or cause is outside the taxonomy | Needs more observation or human triage | Gather more runs, shrink repro, refine category |
|
| 120 |
+
|
| 121 |
+
**Related labels:** `RELATED_CATEGORIES` in `models.py` defines **soft neighbors** (e.g. `async_wait` ↔ `concurrency`) so small taxonomy mismatches in the model’s `think` block do not get punished as harshly during **reasoning consistency** reward.
|
| 122 |
+
|
| 123 |
+
---
|
| 124 |
+
|
| 125 |
+
## 2. Problem we solve
|
| 126 |
+
|
| 127 |
+
**Given** a repository and a test identifier, **stabilize** the failure mode so that the target test (and, ideally, the rest of the suite) passes **repeatably** under the configured runner, without “reward hacking” (weakening assertions, broad `except:`, `skip` markers, or sleeping forever).
|
| 128 |
+
|
| 129 |
+
The environment therefore:
|
| 130 |
+
|
| 131 |
+
- Establishes a **baseline** (preflight: sanity, determinism, flakiness) before training credit is spent.
|
| 132 |
+
- Surfaces **diagnostics** the policy can lean on: stack-derived failure frontier, call chain, I/O “boundary” hints, a **causal** view of the code path, and **AST-only “deep” flakiness** signals.
|
| 133 |
+
- Accepts a **unified** agent turn: structured **diagnosis** + **patch** in one step, then **re-executes** tests and returns **scalar reward** with a full **breakdown** for debugging and GRPO.
|
| 134 |
+
|
| 135 |
+
---
|
| 136 |
+
|
| 137 |
+
## 3. Environment interface (POMDP view)
|
| 138 |
+
|
| 139 |
+
| Concept | In FlakeForge |
|
| 140 |
+
|--------|----------------|
|
| 141 |
+
| **State** (hidden) | Server-side `FlakeForgeState` + git-like snapshots of the repo, episode counters, pass rates, etc. |
|
| 142 |
+
| **Observation** | `FlakeForgeObservation` JSON: sources, trees, preflight, deep signals, causal hints, run history, last reward, and more. |
|
| 143 |
+
| **Action** | `FlakeForgeAction`: unified think + patch (structured JSON and/or free-text for parsing). |
|
| 144 |
+
| **Transition** | `reset` → (optional) configure repo/test; `step` → validate → apply patch → run tests → compute reward. |
|
| 145 |
+
| **Reward** | `compute_verifiable_reward` in `server/reward.py` → `RewardBreakdown` and scalar `total`. |
|
| 146 |
+
|
| 147 |
+
The HTTP layer is created with **OpenEnv**’s `create_app(FlakeForgeEnvironment, FlakeForgeAction, FlakeForgeObservation)`; see `server/app.py` and `openenv.yaml`.
|
| 148 |
+
|
| 149 |
+
---
|
| 150 |
+
|
| 151 |
+
## 4. Observation space
|
| 152 |
+
|
| 153 |
+
The main payload is `FlakeForgeObservation` in `models.py`. At a high level, each step exposes:
|
| 154 |
+
|
| 155 |
+
| Block | Role |
|
| 156 |
+
|-------|------|
|
| 157 |
+
| **Localisation** | `test_function_source`, `source_under_test`, `file_tree`, `relevant_imports`, `async_markers` |
|
| 158 |
+
| **Run dynamics** | `run_history` (`RunRecord`: pass, duration, error_type, stderr excerpt), `current_pass_rate`, `baseline_pass_rate` |
|
| 159 |
+
| **Preflight** | `env_type`, `should_train`, `preflight_result` (three-stage gate: sanity, determinism, flakiness) |
|
| 160 |
+
| **Deep flakiness (AST, fast)** | `module_cache_violations`, `fixture_scope_risks`, `mock_residue_sites`, `import_side_effect_files`, `async_contamination_alive` |
|
| 161 |
+
| **Causal** | `failure_frontier` (file:line:func from stack), `call_chain_to_frontier`, `boundary_crossings` (e.g. HTTP/DB hints), `causal_graph` (summary dict), `causal_hints` |
|
| 162 |
+
| **Failure shape** | `failing_stack_trace`, `failure_pattern_summary`, `duration_fingerprint` |
|
| 163 |
+
| **Probes** | `order_dependency_detected`, `infrastructure_sensitive` (chaos / stress sensitivity) |
|
| 164 |
+
| **Policy feedback** | `patches_applied`, `total_diff_lines`, `think_history` (per-step summary for de-duplication and prompts), `last_think_text`, `last_patch_text`, `last_reward`, `reward_breakdown` |
|
| 165 |
+
| **Termination** | `done`, `done_reason` |
|
| 166 |
+
|
| 167 |
+
The observation is the **ground truth** the unified agent is prompted with on each turn (plus recent history) — see `agent/unified_agent.py` for prompt construction.
|
| 168 |
+
|
| 169 |
+
---
|
| 170 |
+
|
| 171 |
+
## 5. Action space (unified agent)
|
| 172 |
+
|
| 173 |
+
V3 uses a **single** high-level action type: **`UNIFIED_PATCH`**. The policy does **not** pick from seven discrete “tool names” in the old Gym sense; it emits a **JSON object** (preferred) with:
|
| 174 |
+
|
| 175 |
+
- **`think`** — `StructuredThink`: list of `ThinkClaim` items (category, entity, `path::Class.func` `location`, polarity, reason, etc.).
|
| 176 |
+
- **`patch`** — `StructuredPatch`: list of `PatchHunk` with `file`, `search`, `replace` (grounded search/replace over real sources).
|
| 177 |
+
|
| 178 |
+
`FlakeForgeAction` also carries `raw_response`, `think_text`, `patch_text`, and `predicted_category` / `predicted_confidence` for parsers and heuristics.
|
| 179 |
+
|
| 180 |
+
**Intuition:** the “action space” is **constrained by validation**, not a small enum. Invalid JSON, non-matching `search` blocks, unsafe edits, and hack patterns are rejected or penalized; valid minimal edits that move pass rate and match the **failure frontier** score highest.
|
| 181 |
+
|
| 182 |
+
---
|
| 183 |
+
|
| 184 |
+
## 6. Root-cause categories
|
| 185 |
+
|
| 186 |
+
The canonical list is **`ROOT_CAUSE_TYPES`** in `models.py`. Each value is documented with symptoms and fix direction in **[§1 — Flaky test root causes](#flaky-test-root-causes-visual-taxonomy)** (Mermaid mindmap, lifecycle sequence diagram, and reference table).
|
| 187 |
+
|
| 188 |
+
`RELATED_CATEGORIES` defines **soft** relatedness for **reasoning consistency** reward when the model’s stated category and the patch-inferred category differ slightly.
|
| 189 |
+
|
| 190 |
+
---
|
| 191 |
+
|
| 192 |
+
## 7. Unified agent: what it outputs and what it sees
|
| 193 |
+
|
| 194 |
+
**`UnifiedFlakeForgeAgent`** (`agent/unified_agent.py`) is the **JSON-first** interface to your base model. It:
|
| 195 |
+
|
| 196 |
+
- Builds a long context from the current **`FlakeForgeObservation`** (test code, SUT, signals, run summaries, and history).
|
| 197 |
+
- Enforces a **strict JSON** shape (no markdown fences) with **one claim line** in the system prompt; patch hunks use **verifiable** one-line `search` anchors where possible.
|
| 198 |
+
- Parses model output into `structured_think` and `structured_patch` when present.
|
| 199 |
+
|
| 200 |
+
**What the model “sees”** is exactly what you put in the observation: flaky vs stable preflight, pass rates, stack frontier, **deep** flags, and causal summaries — the sort of information you would give a human before asking for a **minimal** fix.
|
| 201 |
+
|
| 202 |
+
---
|
| 203 |
+
|
| 204 |
+
## 8. Core tooling
|
| 205 |
+
|
| 206 |
+
### Causal graph engine
|
| 207 |
+
|
| 208 |
+
*Implementation: `server/causal_graph.py`.*
|
| 209 |
+
|
| 210 |
+
**Purpose:** Build a **directed, cross-file** view from the test entry into user code, marking **async**, **call depth**, and **external boundaries** (HTTP, DB, queue, gRPC) where non-determinism or ordering often enters.
|
| 211 |
+
|
| 212 |
+
- **Input:** repository root, test / entry heuristics, configurable depth.
|
| 213 |
+
- **Output:** a `CausalGraph` (nodes, edges, boundary warnings) rendered into `FlakeForgeObservation.causal_graph` and short **`causal_hints`** for the LLM.
|
| 214 |
+
- **Why it matters:** Patches that edit files **far** from the **failure frontier** or **causal** neighborhood are easy to down-weight; see **causal_proximity_reward** in `server/reward.py`.
|
| 215 |
+
|
| 216 |
+
### Deep flakiness scanners
|
| 217 |
+
|
| 218 |
+
*Implementation: `server/deep_flakiness.py`.*
|
| 219 |
+
|
| 220 |
+
**Purpose:** Inexpensive **AST-only** checks that mirror real flaky patterns from SE literature:
|
| 221 |
+
|
| 222 |
+
- Module-level **caches** / mutable defaults / globals (`@lru_cache`, mutable list defaults, etc.)
|
| 223 |
+
- **Fixture** scope and yield/teardown issues
|
| 224 |
+
- **`mock.patch`** without proper teardown
|
| 225 |
+
- **Import-time** side effects
|
| 226 |
+
- **Async/thread** “leftover” heuristics
|
| 227 |
+
|
| 228 |
+
`build_deep_observation_signals(repo_path)` returns a small dict; the environment copies fields onto the observation so the **policy** and **reward** can use them as **hard features** (no model call).
|
| 229 |
+
|
| 230 |
+
---
|
| 231 |
+
|
| 232 |
+
## 9. Verification stack: oracle engine and patch validator
|
| 233 |
+
|
| 234 |
+
### Oracle engine (`server/oracle_engine.py`)
|
| 235 |
+
|
| 236 |
+
**Question:** *Is the structured **think** consistent with the **code** and the **patch**?*
|
| 237 |
+
|
| 238 |
+
- Uses **AST** and optional **LibCST** for static checks.
|
| 239 |
+
- Resolves `ThinkClaim.location` to real sources; checks things like **sync primitives** for concurrency-style claims, and whether hunks **address** the claimed locus.
|
| 240 |
+
- `verify_structured_think` produces a scalar **`oracle_score`** used in reward when available.
|
| 241 |
+
|
| 242 |
+
This is **not** a “judge model”: it is **code-facing** and **template-aware**, intended to be reproducible and cheap.
|
| 243 |
+
|
| 244 |
+
### Patch validator (`server/patch_validator.py`)
|
| 245 |
+
|
| 246 |
+
**Question:** *Is the patch **well-formed**, **applicable**, and **not obviously abusing** the reward?*
|
| 247 |
+
|
| 248 |
+
**Stages** (from module docstring):
|
| 249 |
+
|
| 250 |
+
1. **Format** — search/replace blocks, headers, basic hygiene
|
| 251 |
+
2. **Simulate** apply — `search` must match the file (via `simulate_search_replace_patch`)
|
| 252 |
+
3. **Syntax** — `ast.parse` on post-patch text
|
| 253 |
+
4. **Compile** — per-module `compile` check
|
| 254 |
+
5. **Structure** — heuristics (e.g. empty bodies, control-flow red flags)
|
| 255 |
+
6. **Causal proximity (warning)** — far-from-frontier warnings
|
| 256 |
+
|
| 257 |
+
**Invalid patches are rejected *before* disk write** in `FlakeForgeEnvironment.step`. The validator can emit a **score** (`validation_score`) that flows into **patch_validation_signal** in reward when the patch actually applies.
|
| 258 |
+
|
| 259 |
+
---
|
| 260 |
+
|
| 261 |
+
## 10. Verifiable reward system
|
| 262 |
+
|
| 263 |
+
Training a policy on **natural-language “scores” from another LLM** is fast to prototype but fragile: the judge can be **gamed**, **biased toward fluent nonsense**, and **misaligned** with what CI actually cares about (repeatable green builds). FlakeForge instead follows a classic principle from **reinforcement learning** and **program repair** research: put most of the learning signal on **outcomes and checks you can re-run**, then add **light shaping** so credit goes to edits that look like real engineering—not clever ways to pass a rubric.
|
| 264 |
+
|
| 265 |
+
**Reading map (how the pieces fit):**
|
| 266 |
+
|
| 267 |
+
1. **Gates first** — If the action is not parseable or the patch does not apply cleanly, there is little point in interpreting test outcomes; the environment **short-circuits** to a strong negative total (see below). This mirrors *constraint-based* action spaces in RL and *reject sampling* in repair pipelines.
|
| 268 |
+
2. **Primary outcome** — **Pass rate** under repeated `pytest` is the main “task reward”; shaping uses a **potential function** of pass rate so improvements are comparable across episodes.
|
| 269 |
+
3. **Secondary structure** — Proximity to the **failure frontier**, **entropy** of failure *types*, and **anti-hack** heuristics encode inductive biases from flaky-test studies and APR practice: fix the **right** place, collapse **chaotic** failure modes, and do not **delete** the test’s meaning.
|
| 270 |
+
4. **Reasoning channel** — When the **oracle** (`verify_structured_think`) runs, it supplies a **code-grounded** score; otherwise a lighter **category consistency** term ties the stated root cause to what the patch actually does.
|
| 271 |
+
|
| 272 |
+
`compute_verifiable_reward` in `server/reward.py` assembles a **`RewardBreakdown`**. The table below is the **contract**; the **weights** and **thresholds** in code are authoritative (see the `breakdown.total_reward = round(...)` block and the gate on `format_reward` / `compile_reward` / `anti_hack_penalty`).
|
| 273 |
+
|
| 274 |
+
| Signal | Role |
|
| 275 |
+
|--------|------|
|
| 276 |
+
| **format_reward** | Parses structured think/patch; rewards valid claims/hunks; falls back to text markers if needed. |
|
| 277 |
+
| **compile_reward** | Patch applied, syntax/compile path; negative if **validator rejected** or not applied. |
|
| 278 |
+
| **stability_reward** | **Potential-based** shaping on pass rate: \(\Phi(p)=p^2\), difference vs baseline. |
|
| 279 |
+
| **causal_proximity_reward** | Edits **near** `failure_frontier` / call chain; uses boundary metadata. |
|
| 280 |
+
| **failure_entropy_reward** | Lower entropy of error **types** after the step is better (more “single failure mode”). |
|
| 281 |
+
| **anti_hack_penalty** | Counters assertion weakening, `sleep` insertion, `except:`, `skip` markers, huge diffs, etc. |
|
| 282 |
+
| **regression_penalty** | Other tests failing after edit. |
|
| 283 |
+
| **reasoning_consistency_reward** / **oracle_reasoning_reward** | Category match (heuristic) vs **oracle** path when `oracle_score` is present. |
|
| 284 |
+
| **patch_validation_signal** | From `PatchValidator` **score** (when applied). |
|
| 285 |
+
| **noop_patch_penalty** | Trivial or no-op edits that still “succeed” mechanically. |
|
| 286 |
+
| **think_history_penalty** | Repeating the same diagnosis without new evidence. |
|
| 287 |
+
| **terminal_bonus** | Large bonus when pass rate hits **1.0** or major improvements. |
|
| 288 |
+
|
| 289 |
+
### Intuition per signal (why it is there)
|
| 290 |
+
|
| 291 |
+
- **format_reward** — A structured **hypothesis + edit** is easier to validate, audit, and compose with oracles than a blob of prose. This is the same motivation as *chain-of-thought* interfaces in tool-using agents: separate **intent** from **mechanism** so downstream code can check both.
|
| 292 |
+
- **compile_reward** — Program repair literature treats **plausible but non-compiling** patches as a dominant failure mode; rewarding **apply + parse + compile** filters a huge class of spurious edits before tests even speak.
|
| 293 |
+
- **stability_reward** (\(\Phi(p)=p^2\)) — Squaring pass rate makes **late** progress toward a fully green suite **more** valuable than the same absolute gain from a very red baseline, which matches the “last mile” cost of killing residual flakes. Formally, the step reward uses a **potential difference** \(\Phi(p_{\text{after}})-\Phi(p_{\text{before}})\), which preserves the **optimal policy** under standard assumptions (see references below).
|
| 294 |
+
- **causal_proximity_reward** — Flaky failures often localize to a small **dynamic slice** of the codebase; patches that edit the **frontier file** or **stack neighborhood** are more likely to be **minimal** and **causal** than edits three modules away. This aligns with **fault localization** and **minimal repair** goals in APR.
|
| 295 |
+
- **failure_entropy_reward** — If every rerun fails differently, the system is still **non-stationary** from the agent’s perspective. **Collapsing** diversity of `error_type` labels is a cheap proxy for “we moved from chaotic nondeterminism toward a **single** addressable failure mode”—consistent with how flaky-test studies categorize **order-, async-, and state-dependent** symptoms.
|
| 296 |
+
- **anti_hack_penalty** — Any scalar reward invites **Goodharting**: weaken assertions, swallow exceptions, skip tests, or sleep until races vanish. Penalties target **known** shortcuts so the policy cannot trade away **semantic** test intent for **numeric** reward (see **specification gaming** references).
|
| 297 |
+
- **regression_penalty** — A fix that **breaks neighbors** is not acceptable in CI; this term encodes a **multi-test** notion of correctness familiar from regression testing research.
|
| 298 |
+
- **oracle_reasoning_reward** vs **reasoning_consistency_reward** — When the oracle runs, reasoning is graded by **static alignment** between claims, locations, and hunks—closer to **process supervision** (“check the steps”) than to an LLM grader. Without the oracle, a lighter **taxonomy** match keeps the stated category from drifting entirely away from the patch.
|
| 299 |
+
- **patch_validation_signal**, **noop_patch_penalty**, **think_history_penalty** — Respectively: reward **quality of application** beyond binary success, block **empty** credit loops, and nudge the policy away from **repeating** the same ineffective narrative when the environment has not changed.
|
| 300 |
+
- **terminal_bonus** — Sparse **success** signals (full pass or large jumps) are emphasized so GRPO-style training does not drown in **tiny** shaped increments.
|
| 301 |
+
|
| 302 |
+
### Hard short-circuit (credit only when basics pass)
|
| 303 |
+
|
| 304 |
+
If **format** is too low, **compile** is bad, or **anti_hack** is negative, the total is clamped to a **strong negative** (see the `if breakdown.format_reward < 0.75 or ...` block in `server/reward.py`) so the policy cannot grind partial credit on garbage.
|
| 305 |
+
|
| 306 |
+
### Weighted total
|
| 307 |
+
|
| 308 |
+
**Total** is a **weighted sum** of the terms above; coefficients are in the final `breakdown.total_reward = round(...)` assignment in `server/reward.py` (e.g. stability is up-weighted relative to format because **outcome** is the primary learning signal once gates pass).
|
| 309 |
+
|
| 310 |
+
### Research connections (selected)
|
| 311 |
+
|
| 312 |
+
These papers and reports are not an exhaustive bibliography, but they justify **why** each family of terms exists:
|
| 313 |
+
|
| 314 |
+
| Theme | Pointer | Relevance to FlakeForge |
|
| 315 |
+
|-------|---------|-------------------------|
|
| 316 |
+
| **Potential-based shaping** | Ng, Harada, Russell, [*Policy Invariance Under Reward Transformations: Theory and Application to Reward Shaping*](https://people.eecs.berkeley.edu/~russell/papers/icml99-shaping.pdf) (ICML 1999) | Justifies \(\Phi(p)=p^2\) and **difference** shaping so auxiliary reward does not invent spurious optima (implementation comment in `server/reward.py`). |
|
| 317 |
+
| **Flaky tests (empirics)** | Luo et al., [*An Empirical Analysis of Flaky Tests*](https://dl.acm.org/doi/10.1145/2635868.2635920) (FSE 2014) | Motivates **repeatable** evaluation, **order/async** failure modes, and why **pass-rate stability** must be central. |
|
| 318 |
+
| **Process / verifiable feedback** | Lightman et al., [*Let’s Verify Step by Step*](https://arxiv.org/abs/2305.20050) (ICLR 2024) | Intuition for **oracle_score**: reward **checkable** reasoning traces, not just final answers. |
|
| 319 |
+
| **Outcome training for reasoning** | Uesato et al., [*Solving Math Word Problems with Process- and Outcome-based Feedback*](https://arxiv.org/abs/2211.14275) (2022) | Complements the above: **outcome** (pytest) + **process** (oracle) hybrid. |
|
| 320 |
+
| **Specification gaming** | Krakovna, [*Specification gaming examples in AI*](https://vkrakovna.wordpress.com/2018/04/02/specification-gaming-examples-in-ai/) (blog, 2018); Amodei et al., [*Concrete Problems in AI Safety*](https://arxiv.org/abs/1606.06565) (2016) | Motivates **anti_hack** and **hard gates**: misspecified rewards invite **perverse** shortcuts. |
|
| 321 |
+
| **Program repair context** | Monperrus, [*Automatic Software Repair: A Bibliography*](https://hal.science/hal-01956501v1/document) (ACM CSUR 2018) | Surveys **compile-time** filters, **localization**, and **regression** awareness—same structural pressures as this reward stack. |
|
| 322 |
+
| **Group RL for LLMs** | Shao et al., [*DeepSeekMath: Pushing the Limits of Mathematical Reasoning in Open Language Models*](https://arxiv.org/abs/2402.03300) (2024) | **GRPO**-style training benefits from **low-variance**, **comparable** rewards across a group; `RewardBreakdown` is designed for exactly that kind of logging and advantage estimation. |
|
| 323 |
+
|
| 324 |
+
**Intuition in one line:** *Reward what **pytest and static checks** can verify, punish **hacks and regressions**, and gently align **stated** diagnosis with **actual** code change—using shaping theory so the extras do not fight the main objective.*
|
| 325 |
+
|
| 326 |
+
---
|
| 327 |
+
|
| 328 |
+
## 11. OpenEnv config and commands
|
| 329 |
+
|
| 330 |
+
`openenv.yaml` pins the app entry point:
|
| 331 |
+
|
| 332 |
+
```yaml
|
| 333 |
+
spec_version: 1
|
| 334 |
+
name: FlakeForge
|
| 335 |
+
type: space
|
| 336 |
+
runtime: fastapi
|
| 337 |
+
app: server.app:app
|
| 338 |
+
port: 8000
|
| 339 |
+
```
|
| 340 |
+
|
| 341 |
+
**Typical commands (from a clone of this repo, repo root = `CWD`):**
|
| 342 |
+
|
| 343 |
+
| Task | Command |
|
| 344 |
+
|------|--------|
|
| 345 |
+
| Install deps (pip) | `pip install -r server/requirements.txt` |
|
| 346 |
+
| **Run ASGI** (dev) | `uvicorn server.app:app --host 0.0.0.0 --port 8000` |
|
| 347 |
+
| **Run** (if the package is installed with `[project.scripts]`) | `uv run server --port 8000` (see `test_repos/SETUP.md`) |
|
| 348 |
+
| **Docker** | `docker build -t flakeforge-env:latest -f server/Dockerfile .` then run with published `8000` |
|
| 349 |
+
| **HTTP health** | `GET http://localhost:8000/health` |
|
| 350 |
+
| **Episode (HTTP API)** | `POST /reset` with JSON body; then `POST /step` with action fields (see `client.py` and OpenEnv’s handler) |
|
| 351 |
+
|
| 352 |
+
**Hugging Face Hub CLI (optional) — push Space files:**
|
| 353 |
+
|
| 354 |
+
```bash
|
| 355 |
+
hf auth login
|
| 356 |
+
hf upload <user>/FlakeForge . --type space
|
| 357 |
+
```
|
| 358 |
+
|
| 359 |
+
(Use your Space repo id. Requires `huggingface_hub` / [`hf` CLI](https://huggingface.co/docs/huggingface_hub/main/en/guides/cli).)
|
| 360 |
+
|
| 361 |
+
The Space UI in this project serves `templates/index.html` at `/` and adds showcase routes under `/api/*` (see `server/api_showcase.py`).
|
| 362 |
+
|
| 363 |
+
---
|
| 364 |
+
|
| 365 |
+
## 12. Build, run, and test locally
|
| 366 |
+
|
| 367 |
+
**In-process (Python):** with the package installed (`pip install -e .` or your env manager):
|
| 368 |
+
|
| 369 |
+
```python
|
| 370 |
+
from FlakeForge.server.FlakeForge_environment import FlakeForgeEnvironment
|
| 371 |
+
from FlakeForge.models import FlakeForgeAction
|
| 372 |
+
|
| 373 |
+
env = FlakeForgeEnvironment() # optional: repo_path, test_identifier
|
| 374 |
+
obs = env.reset()
|
| 375 |
+
# Build FlakeForgeAction with your model or tests; then:
|
| 376 |
+
# result = env.step(action) # return shape from OpenEnv Environment API
|
| 377 |
+
```
|
| 378 |
+
|
| 379 |
+
If you run from a source checkout without installing, set `PYTHONPATH` to the repo root and use `from server.FlakeForge_environment import FlakeForgeEnvironment` (and `from models import ...` as in `tests/`).
|
| 380 |
+
|
| 381 |
+
**HTTP client:** see `client.py` (`FlakeForgeClient.run_episode_remote`) for `httpx` calls to `POST /reset` and `POST /step` against `ENV_BASE_URL`.
|
| 382 |
+
|
| 383 |
+
**Image build** uses `server/Dockerfile` (see top of that file for `HEALTHCHECK` and `uvicorn` entrypoint).
|
| 384 |
+
|
| 385 |
+
### Inference log (`outputs/inference.log`)
|
| 386 |
+
|
| 387 |
+
Running **`python inference.py`** (with your usual args for repo path, test id, and model) appends **structured episode logs** to **`outputs/inference.log`** (see `inference.py`: the logger is created with that path). The same run also **prints the full episode result as JSON** to stdout when the script finishes, so you can copy a trajectory for post-mortems. That JSON is the in-memory `episode_result` from `run_episode`: `trajectory` (per-step think/patch/reward), `total_reward`, `final_pass_rate`, and `done_reason`.
|
| 388 |
+
|
| 389 |
+
**Example: bad policy on a queue-under-load / concurrency case.** The episode below is from a run against a **small worker-pool** fixture (flaky test exercising **`WorkerPool`** and **`QUEUE_CAPACITY`** in `source.py`, in the same family as `test_repos/moderate_load_jitter_flaky`—concurrent submissions vs a tight queue). The model repeatedly **raises queue capacity** and insists the root cause is “small capacity → false failures,” but **pass rate never moves off zero** and the session ends on **`regression_detected`**.
|
| 390 |
+
|
| 391 |
+
**Why the agent is poor here (reward-aligned):**
|
| 392 |
+
|
| 393 |
+
- **Patch quality:** Step 1 replaces `QUEUE_CAPACITY = 5` with `WorkerPool.QUEUE_CAPACITY = 10` in a way that is **not a valid or stable edit** to the class body (the breakdown still shows `compile` 1.0 in some steps, but the trajectory illustrates **fuzzy/fragile** edits and later `patch_applied: false` when search text no longer matches). The policy **fixates** on a single knob instead of the actual failure mode the tests encode.
|
| 394 |
+
- **No measurable improvement:** `pass_rate` is **0.0** every step, so the environment never sees **stability** improve; that keeps **`stability` negative** and exposes **regression** risk when the patch moves the code away from the reference behavior.
|
| 395 |
+
- **Reasoning not grounded in execution:** **Oracle / reasoning** terms (`oracle_reasoning`, and related breakdown fields) stay negative: the high-confidence **concurrency** narrative does not match what **pytest** can verify, so the verifiable stack punishes “sounds right but isn’t” claims.
|
| 396 |
+
- **Repetition without learning:** `think_history` turns negative in later steps—similar claims and the same class of capacity tweak yield **increasingly wasteful** trajectories.
|
| 397 |
+
- **Outcome:** **`total_reward`: -24.55**, **`done_reason`:** `regression_detected`, after eight steps still at **`final_pass_rate`: 0.0**. This is exactly the kind of run you want the reward to make **unattractive** for training or evaluation: confident language, **no** pass-rate lift, and **guardrail** terms firing.
|
| 398 |
+
|
| 399 |
+
```json
|
| 400 |
+
{
|
| 401 |
+
"trajectory": [
|
| 402 |
+
{
|
| 403 |
+
"step": 1,
|
| 404 |
+
"predicted_category": "concurrency",
|
| 405 |
+
"predicted_confidence": 0.9,
|
| 406 |
+
"think_text": "{\"claims\": [{\"category\": \"concurrency\", \"entity\": \"QUEUE_CAPACITY\", \"location\": \"source.py::WorkerPool\", \"polarity\": \"present\", \"reason\": \"small capacity causes false positives during concurrent load\"}], \"confidence\": 0.9}",
|
| 407 |
+
"patch_text": "--- source.py\n<<<<<<< SEARCH\nQUEUE_CAPACITY = 5\n=======\nWorkerPool.QUEUE_CAPACITY = 10\n>>>>>>> REPLACE",
|
| 408 |
+
"patch_applied": true,
|
| 409 |
+
"reward": -3.55,
|
| 410 |
+
"reward_breakdown": {
|
| 411 |
+
"format": 1.0,
|
| 412 |
+
"compile": 1.0,
|
| 413 |
+
"stability": -1.0,
|
| 414 |
+
"causal_proximity": 0.0,
|
| 415 |
+
"failure_entropy": 0.5,
|
| 416 |
+
"anti_hack": 0.0,
|
| 417 |
+
"regression": -1.5,
|
| 418 |
+
"reasoning_consistency": 0.0,
|
| 419 |
+
"oracle_reasoning": -1.0,
|
| 420 |
+
"patch_validation": 0.2,
|
| 421 |
+
"noop_patch": 0.0,
|
| 422 |
+
"think_history": 0.0,
|
| 423 |
+
"terminal_bonus": 0.0,
|
| 424 |
+
"total": -3.55
|
| 425 |
+
},
|
| 426 |
+
"pass_rate": 0.0,
|
| 427 |
+
"done": false
|
| 428 |
+
},
|
| 429 |
+
{
|
| 430 |
+
"step": 2,
|
| 431 |
+
"predicted_category": "concurrency",
|
| 432 |
+
"predicted_confidence": 0.9,
|
| 433 |
+
"think_text": "{\"claims\": [{\"category\": \"concurrency\", \"entity\": \"QUEUE_CAPACITY\", \"location\": \"source.py::WorkerPool\", \"polarity\": \"present\", \"reason\": \"small queue capacity causes false positives during concurrent load\"}], \"confidence\": 0.9}",
|
| 434 |
+
"patch_text": "--- source.py\n<<<<<<< SEARCH\nWorkerPool.QUEUE_CAPACITY = 10\n=======\nWorkerPool.QUEUE_CAPACITY = 50\n>>>>>>> REPLACE",
|
| 435 |
+
"patch_applied": true,
|
| 436 |
+
"reward": -3.8,
|
| 437 |
+
"reward_breakdown": {
|
| 438 |
+
"format": 1.0,
|
| 439 |
+
"compile": 1.0,
|
| 440 |
+
"stability": -1.0,
|
| 441 |
+
"causal_proximity": 0.0,
|
| 442 |
+
"failure_entropy": 0.5,
|
| 443 |
+
"anti_hack": 0.0,
|
| 444 |
+
"regression": -1.5,
|
| 445 |
+
"reasoning_consistency": 0.0,
|
| 446 |
+
"oracle_reasoning": -1.0,
|
| 447 |
+
"patch_validation": 0.2,
|
| 448 |
+
"noop_patch": 0.0,
|
| 449 |
+
"think_history": -0.25,
|
| 450 |
+
"terminal_bonus": 0.0,
|
| 451 |
+
"total": -3.8
|
| 452 |
+
},
|
| 453 |
+
"pass_rate": 0.0,
|
| 454 |
+
"done": false
|
| 455 |
+
},
|
| 456 |
+
{
|
| 457 |
+
"step": 3,
|
| 458 |
+
"predicted_category": "concurrency",
|
| 459 |
+
"predicted_confidence": 0.9,
|
| 460 |
+
"think_text": "{\"claims\": [{\"category\": \"concurrency\", \"entity\": \"QUEUE_CAPACITY\", \"location\": \"source.py::WorkerPool\", \"polarity\": \"present\", \"reason\": \"small capacity leads to false queue-full reports\"}], \"confidence\": 0.9}",
|
| 461 |
+
"patch_text": "--- source.py\n<<<<<<< SEARCH\nWorkerPool.QUEUE_CAPACITY = 50\n=======\nWorkerPool.QUEUE_CAPACITY = 100\n>>>>>>> REPLACE",
|
| 462 |
+
"patch_applied": true,
|
| 463 |
+
"reward": -3.8,
|
| 464 |
+
"reward_breakdown": {
|
| 465 |
+
"format": 1.0,
|
| 466 |
+
"compile": 1.0,
|
| 467 |
+
"stability": -1.0,
|
| 468 |
+
"causal_proximity": 0.0,
|
| 469 |
+
"failure_entropy": 0.5,
|
| 470 |
+
"anti_hack": 0.0,
|
| 471 |
+
"regression": -1.5,
|
| 472 |
+
"reasoning_consistency": 0.0,
|
| 473 |
+
"oracle_reasoning": -1.0,
|
| 474 |
+
"patch_validation": 0.2,
|
| 475 |
+
"noop_patch": 0.0,
|
| 476 |
+
"think_history": -0.25,
|
| 477 |
+
"terminal_bonus": 0.0,
|
| 478 |
+
"total": -3.8
|
| 479 |
+
},
|
| 480 |
+
"pass_rate": 0.0,
|
| 481 |
+
"done": false
|
| 482 |
+
},
|
| 483 |
+
{
|
| 484 |
+
"step": 4,
|
| 485 |
+
"predicted_category": "concurrency",
|
| 486 |
+
"predicted_confidence": 0.9,
|
| 487 |
+
"think_text": "{\"claims\": [{\"category\": \"concurrency\", \"entity\": \"QUEUE_CAPACITY\", \"location\": \"source.py::WorkerPool\", \"polarity\": \"present\", \"reason\": \"small queue capacity leads to false positives during concurrent load\"}], \"confidence\": 0.9}",
|
| 488 |
+
"patch_text": "--- source.py\n<<<<<<< SEARCH\nWorkerPool.QUEUE_CAPACITY = 100\n=======\nWorkerPool.QUEUE_CAPACITY = 500\n>>>>>>> REPLACE",
|
| 489 |
+
"patch_applied": true,
|
| 490 |
+
"reward": -3.8,
|
| 491 |
+
"reward_breakdown": {
|
| 492 |
+
"format": 1.0,
|
| 493 |
+
"compile": 1.0,
|
| 494 |
+
"stability": -1.0,
|
| 495 |
+
"causal_proximity": 0.0,
|
| 496 |
+
"failure_entropy": 0.5,
|
| 497 |
+
"anti_hack": 0.0,
|
| 498 |
+
"regression": -1.5,
|
| 499 |
+
"reasoning_consistency": 0.0,
|
| 500 |
+
"oracle_reasoning": -1.0,
|
| 501 |
+
"patch_validation": 0.2,
|
| 502 |
+
"noop_patch": 0.0,
|
| 503 |
+
"think_history": -0.25,
|
| 504 |
+
"terminal_bonus": 0.0,
|
| 505 |
+
"total": -3.8
|
| 506 |
+
},
|
| 507 |
+
"pass_rate": 0.0,
|
| 508 |
+
"done": false
|
| 509 |
+
},
|
| 510 |
+
{
|
| 511 |
+
"step": 5,
|
| 512 |
+
"predicted_category": "concurrency",
|
| 513 |
+
"predicted_confidence": 0.9,
|
| 514 |
+
"think_text": "{\"claims\": [{\"category\": \"concurrency\", \"entity\": \"QUEUE_CAPACITY\", \"location\": \"source.py::WorkerPool.__init__\", \"polarity\": \"present\", \"reason\": \"smaller capacity leads to more frequent failures\"}], \"confidence\": 0.9}",
|
| 515 |
+
"patch_text": "--- source.py\n<<<<<<< SEARCH\nWorkerPool.QUEUE_CAPACITY = 500\n=======\nWorkerPool.QUEUE_CAPACITY = 1000\n>>>>>>> REPLACE",
|
| 516 |
+
"patch_applied": false,
|
| 517 |
+
"reward": -1.0,
|
| 518 |
+
"reward_breakdown": {
|
| 519 |
+
"format": 1.0,
|
| 520 |
+
"compile": -0.5,
|
| 521 |
+
"stability": 0.0,
|
| 522 |
+
"causal_proximity": 0.0,
|
| 523 |
+
"failure_entropy": 0.0,
|
| 524 |
+
"anti_hack": 0.0,
|
| 525 |
+
"regression": -1.5,
|
| 526 |
+
"reasoning_consistency": 0.0,
|
| 527 |
+
"oracle_reasoning": 0.0,
|
| 528 |
+
"patch_validation": 0.0,
|
| 529 |
+
"noop_patch": 0.0,
|
| 530 |
+
"think_history": 0.0,
|
| 531 |
+
"terminal_bonus": 0.0,
|
| 532 |
+
"total": -1.0
|
| 533 |
+
},
|
| 534 |
+
"pass_rate": 0.0,
|
| 535 |
+
"done": false
|
| 536 |
+
},
|
| 537 |
+
{
|
| 538 |
+
"step": 6,
|
| 539 |
+
"predicted_category": "concurrency",
|
| 540 |
+
"predicted_confidence": 0.9,
|
| 541 |
+
"think_text": "{\"claims\": [{\"category\": \"concurrency\", \"entity\": \"QUEUE_CAPACITY\", \"location\": \"source.py::WorkerPool.__init__\", \"polarity\": \"present\", \"reason\": \"smaller capacity leads to more frequent failures\"}], \"confidence\": 0.9}",
|
| 542 |
+
"patch_text": "--- source.py\n<<<<<<< SEARCH\nWorkerPool.QUEUE_CAPACITY = 500\n=======\nWorkerPool.QUEUE_CAPACITY = 1000\n>>>>>>> REPLACE",
|
| 543 |
+
"patch_applied": false,
|
| 544 |
+
"reward": -1.0,
|
| 545 |
+
"reward_breakdown": {
|
| 546 |
+
"format": 1.0,
|
| 547 |
+
"compile": -0.5,
|
| 548 |
+
"stability": 0.0,
|
| 549 |
+
"causal_proximity": 0.0,
|
| 550 |
+
"failure_entropy": 0.0,
|
| 551 |
+
"anti_hack": 0.0,
|
| 552 |
+
"regression": -1.5,
|
| 553 |
+
"reasoning_consistency": 0.0,
|
| 554 |
+
"oracle_reasoning": 0.0,
|
| 555 |
+
"patch_validation": 0.0,
|
| 556 |
+
"noop_patch": 0.0,
|
| 557 |
+
"think_history": 0.0,
|
| 558 |
+
"terminal_bonus": 0.0,
|
| 559 |
+
"total": -1.0
|
| 560 |
+
},
|
| 561 |
+
"pass_rate": 0.0,
|
| 562 |
+
"done": false
|
| 563 |
+
},
|
| 564 |
+
{
|
| 565 |
+
"step": 7,
|
| 566 |
+
"predicted_category": "concurrency",
|
| 567 |
+
"predicted_confidence": 0.9,
|
| 568 |
+
"think_text": "{\"claims\": [{\"category\": \"concurrency\", \"entity\": \"QUEUE_CAPACITY\", \"location\": \"source.py::WorkerPool\", \"polarity\": \"present\", \"reason\": \"small capacity causes false queue-full reports\"}], \"confidence\": 0.9}",
|
| 569 |
+
"patch_text": "--- source.py\n<<<<<<< SEARCH\nWorkerPool.QUEUE_CAPACITY = 500\n=======\nWorkerPool.QUEUE_CAPACITY = 1000\n>>>>>>> REPLACE",
|
| 570 |
+
"patch_applied": true,
|
| 571 |
+
"reward": -3.8,
|
| 572 |
+
"reward_breakdown": {
|
| 573 |
+
"format": 1.0,
|
| 574 |
+
"compile": 1.0,
|
| 575 |
+
"stability": -1.0,
|
| 576 |
+
"causal_proximity": 0.0,
|
| 577 |
+
"failure_entropy": 0.5,
|
| 578 |
+
"anti_hack": 0.0,
|
| 579 |
+
"regression": -1.5,
|
| 580 |
+
"reasoning_consistency": 0.0,
|
| 581 |
+
"oracle_reasoning": -1.0,
|
| 582 |
+
"patch_validation": 0.2,
|
| 583 |
+
"noop_patch": 0.0,
|
| 584 |
+
"think_history": -0.25,
|
| 585 |
+
"terminal_bonus": 0.0,
|
| 586 |
+
"total": -3.8
|
| 587 |
+
},
|
| 588 |
+
"pass_rate": 0.0,
|
| 589 |
+
"done": false
|
| 590 |
+
},
|
| 591 |
+
{
|
| 592 |
+
"step": 8,
|
| 593 |
+
"predicted_category": "concurrency",
|
| 594 |
+
"predicted_confidence": 0.9,
|
| 595 |
+
"think_text": "{\"claims\": [{\"category\": \"concurrency\", \"entity\": \"QUEUE_CAPACITY\", \"location\": \"source.py::WorkerPool\", \"polarity\": \"present\", \"reason\": \"small queue capacity causes false positives during concurrent load\"}], \"confidence\": 0.9}",
|
| 596 |
+
"patch_text": "--- source.py\n<<<<<<< SEARCH\nWorkerPool.QUEUE_CAPACITY = 1000\n=======\nWorkerPool.QUEUE_CAPACITY = 5000\n>>>>>>> REPLACE",
|
| 597 |
+
"patch_applied": true,
|
| 598 |
+
"reward": -3.8,
|
| 599 |
+
"reward_breakdown": {
|
| 600 |
+
"format": 1.0,
|
| 601 |
+
"compile": 1.0,
|
| 602 |
+
"stability": -1.0,
|
| 603 |
+
"causal_proximity": 0.0,
|
| 604 |
+
"failure_entropy": 0.5,
|
| 605 |
+
"anti_hack": 0.0,
|
| 606 |
+
"regression": -1.5,
|
| 607 |
+
"reasoning_consistency": 0.0,
|
| 608 |
+
"oracle_reasoning": -1.0,
|
| 609 |
+
"patch_validation": 0.2,
|
| 610 |
+
"noop_patch": 0.0,
|
| 611 |
+
"think_history": -0.25,
|
| 612 |
+
"terminal_bonus": 0.0,
|
| 613 |
+
"total": -3.8
|
| 614 |
+
},
|
| 615 |
+
"pass_rate": 0.0,
|
| 616 |
+
"done": true
|
| 617 |
+
}
|
| 618 |
+
],
|
| 619 |
+
"total_reward": -24.55,
|
| 620 |
+
"steps": 8,
|
| 621 |
+
"final_pass_rate": 0.0,
|
| 622 |
+
"done_reason": "regression_detected",
|
| 623 |
+
"reward_breakdown_history": [
|
| 624 |
+
{
|
| 625 |
+
"format": 1.0,
|
| 626 |
+
"compile": 1.0,
|
| 627 |
+
"stability": -1.0,
|
| 628 |
+
"causal_proximity": 0.0,
|
| 629 |
+
"failure_entropy": 0.5,
|
| 630 |
+
"anti_hack": 0.0,
|
| 631 |
+
"regression": -1.5,
|
| 632 |
+
"reasoning_consistency": 0.0,
|
| 633 |
+
"oracle_reasoning": -1.0,
|
| 634 |
+
"patch_validation": 0.2,
|
| 635 |
+
"noop_patch": 0.0,
|
| 636 |
+
"think_history": 0.0,
|
| 637 |
+
"terminal_bonus": 0.0,
|
| 638 |
+
"total": -3.55
|
| 639 |
+
},
|
| 640 |
+
{
|
| 641 |
+
"format": 1.0,
|
| 642 |
+
"compile": 1.0,
|
| 643 |
+
"stability": -1.0,
|
| 644 |
+
"causal_proximity": 0.0,
|
| 645 |
+
"failure_entropy": 0.5,
|
| 646 |
+
"anti_hack": 0.0,
|
| 647 |
+
"regression": -1.5,
|
| 648 |
+
"reasoning_consistency": 0.0,
|
| 649 |
+
"oracle_reasoning": -1.0,
|
| 650 |
+
"patch_validation": 0.2,
|
| 651 |
+
"noop_patch": 0.0,
|
| 652 |
+
"think_history": -0.25,
|
| 653 |
+
"terminal_bonus": 0.0,
|
| 654 |
+
"total": -3.8
|
| 655 |
+
},
|
| 656 |
+
{
|
| 657 |
+
"format": 1.0,
|
| 658 |
+
"compile": 1.0,
|
| 659 |
+
"stability": -1.0,
|
| 660 |
+
"causal_proximity": 0.0,
|
| 661 |
+
"failure_entropy": 0.5,
|
| 662 |
+
"anti_hack": 0.0,
|
| 663 |
+
"regression": -1.5,
|
| 664 |
+
"reasoning_consistency": 0.0,
|
| 665 |
+
"oracle_reasoning": -1.0,
|
| 666 |
+
"patch_validation": 0.2,
|
| 667 |
+
"noop_patch": 0.0,
|
| 668 |
+
"think_history": -0.25,
|
| 669 |
+
"terminal_bonus": 0.0,
|
| 670 |
+
"total": -3.8
|
| 671 |
+
},
|
| 672 |
+
{
|
| 673 |
+
"format": 1.0,
|
| 674 |
+
"compile": 1.0,
|
| 675 |
+
"stability": -1.0,
|
| 676 |
+
"causal_proximity": 0.0,
|
| 677 |
+
"failure_entropy": 0.5,
|
| 678 |
+
"anti_hack": 0.0,
|
| 679 |
+
"regression": -1.5,
|
| 680 |
+
"reasoning_consistency": 0.0,
|
| 681 |
+
"oracle_reasoning": -1.0,
|
| 682 |
+
"patch_validation": 0.2,
|
| 683 |
+
"noop_patch": 0.0,
|
| 684 |
+
"think_history": -0.25,
|
| 685 |
+
"terminal_bonus": 0.0,
|
| 686 |
+
"total": -3.8
|
| 687 |
+
},
|
| 688 |
+
{
|
| 689 |
+
"format": 1.0,
|
| 690 |
+
"compile": -0.5,
|
| 691 |
+
"stability": 0.0,
|
| 692 |
+
"causal_proximity": 0.0,
|
| 693 |
+
"failure_entropy": 0.0,
|
| 694 |
+
"anti_hack": 0.0,
|
| 695 |
+
"regression": -1.5,
|
| 696 |
+
"reasoning_consistency": 0.0,
|
| 697 |
+
"oracle_reasoning": 0.0,
|
| 698 |
+
"patch_validation": 0.0,
|
| 699 |
+
"noop_patch": 0.0,
|
| 700 |
+
"think_history": 0.0,
|
| 701 |
+
"terminal_bonus": 0.0,
|
| 702 |
+
"total": -1.0
|
| 703 |
+
},
|
| 704 |
+
{
|
| 705 |
+
"format": 1.0,
|
| 706 |
+
"compile": -0.5,
|
| 707 |
+
"stability": 0.0,
|
| 708 |
+
"causal_proximity": 0.0,
|
| 709 |
+
"failure_entropy": 0.0,
|
| 710 |
+
"anti_hack": 0.0,
|
| 711 |
+
"regression": -1.5,
|
| 712 |
+
"reasoning_consistency": 0.0,
|
| 713 |
+
"oracle_reasoning": 0.0,
|
| 714 |
+
"patch_validation": 0.0,
|
| 715 |
+
"noop_patch": 0.0,
|
| 716 |
+
"think_history": 0.0,
|
| 717 |
+
"terminal_bonus": 0.0,
|
| 718 |
+
"total": -1.0
|
| 719 |
+
},
|
| 720 |
+
{
|
| 721 |
+
"format": 1.0,
|
| 722 |
+
"compile": 1.0,
|
| 723 |
+
"stability": -1.0,
|
| 724 |
+
"causal_proximity": 0.0,
|
| 725 |
+
"failure_entropy": 0.5,
|
| 726 |
+
"anti_hack": 0.0,
|
| 727 |
+
"regression": -1.5,
|
| 728 |
+
"reasoning_consistency": 0.0,
|
| 729 |
+
"oracle_reasoning": -1.0,
|
| 730 |
+
"patch_validation": 0.2,
|
| 731 |
+
"noop_patch": 0.0,
|
| 732 |
+
"think_history": -0.25,
|
| 733 |
+
"terminal_bonus": 0.0,
|
| 734 |
+
"total": -3.8
|
| 735 |
+
},
|
| 736 |
+
{
|
| 737 |
+
"format": 1.0,
|
| 738 |
+
"compile": 1.0,
|
| 739 |
+
"stability": -1.0,
|
| 740 |
+
"causal_proximity": 0.0,
|
| 741 |
+
"failure_entropy": 0.5,
|
| 742 |
+
"anti_hack": 0.0,
|
| 743 |
+
"regression": -1.5,
|
| 744 |
+
"reasoning_consistency": 0.0,
|
| 745 |
+
"oracle_reasoning": -1.0,
|
| 746 |
+
"patch_validation": 0.2,
|
| 747 |
+
"noop_patch": 0.0,
|
| 748 |
+
"think_history": -0.25,
|
| 749 |
+
"terminal_bonus": 0.0,
|
| 750 |
+
"total": -3.8
|
| 751 |
+
}
|
| 752 |
+
]
|
| 753 |
+
}
|
| 754 |
+
```
|
| 755 |
+
|
| 756 |
+
---
|
| 757 |
+
|
| 758 |
+
## 13. Deploy on Hugging Face Spaces
|
| 759 |
+
|
| 760 |
+
1. **Create** a new Space; set **SDK** to **Docker** (matches this repo’s README front matter: `sdk: docker`).
|
| 761 |
+
2. **Port:** `app_port: 8000` (already in the YAML front matter of this `README.md`).
|
| 762 |
+
3. **Container:** Hugging Face expects a `Dockerfile` at the **repository root** of the Space. This repository ships the canonical image in **`server/Dockerfile`**. For Spaces, add a **root** `Dockerfile` with the same content (or `COPY` pattern), e.g.:
|
| 763 |
+
|
| 764 |
+
```dockerfile
|
| 765 |
+
FROM python:3.11-slim
|
| 766 |
+
WORKDIR /app
|
| 767 |
+
RUN apt-get update && apt-get install -y --no-install-recommends git curl stress-ng iproute2 \
|
| 768 |
+
&& rm -rf /var/lib/apt/lists/*
|
| 769 |
+
COPY server/requirements.txt /app/server/requirements.txt
|
| 770 |
+
RUN pip install --no-cache-dir -r /app/server/requirements.txt
|
| 771 |
+
COPY . /app/
|
| 772 |
+
HEALTHCHECK --interval=10s --timeout=3s --start-period=10s --retries=5 \
|
| 773 |
+
CMD curl -f http://localhost:8000/health || exit 1
|
| 774 |
+
EXPOSE 8000
|
| 775 |
+
ENTRYPOINT ["uvicorn", "server.app:app", "--host", "0.0.0.0", "--port", "8000"]
|
| 776 |
+
```
|
| 777 |
+
|
| 778 |
+
4. **Environment (optional):** set `FF_REPO_PATH` to point at a **bundled** repo in the image (default in code: `test_repos/timing_race_minimal` under the project root) or your checked-in fixture.
|
| 779 |
+
5. **Push** via Git (recommended) or `hf upload` as above. Wait for the build; the UI should load at the Space URL.
|
| 780 |
+
|
| 781 |
+
**Note:** The OpenEnv `create_app` stack exposes `GET /health`, `POST /reset`, and `POST /step` like other OpenEnv HTTP servers. OpenAPI may be at `/docs` if enabled by your `openenv-core` version.
|
| 782 |
+
|
| 783 |
+
---
|
| 784 |
+
|
| 785 |
+
## 14. RL training (Unsloth, curriculum)
|
| 786 |
+
|
| 787 |
+
**Idea:** GRPO rollouts use **`FlakeForgeEnvironment`**, **structured JSON** actions, and **`compute_verifiable_reward`** — no LLM judge for the main training signal.
|
| 788 |
+
|
| 789 |
+
**Current stack (after latest improvements):**
|
| 790 |
+
|
| 791 |
+
- **Unsloth GRPOTrainer** (default) — fast path vs vanilla TRL; strong Qwen2.5 support; 4-bit QLoRA
|
| 792 |
+
- **Curriculum** with 3 stages (Easy → Medium → Hard)
|
| 793 |
+
- **Manifest-grounded** scenarios under `test_repos/synthetic/`, `data/manifests/`, and `data/curriculum_stages/`
|
| 794 |
+
|
| 795 |
+
**Install training deps and run the driver:**
|
| 796 |
+
|
| 797 |
+
```bash
|
| 798 |
+
uv pip install -r training-requirements.txt
|
| 799 |
+
python -m training.train_grpo --model Qwen/Qwen2.5-7B-Instruct --max-episodes 500
|
| 800 |
+
```
|
| 801 |
+
|
| 802 |
+
**Notes:**
|
| 803 |
+
|
| 804 |
+
- Unsloth is **required** for the default training path; the script fails fast with install hints if it is missing.
|
| 805 |
+
- Outputs use the **V3** JSON shape `{"think": {...}, "patch": {...}}`.
|
| 806 |
+
- W&B project defaults to `flakeforge-rl` when enabled.
|
| 807 |
+
- The notebook `FlakeForge_RL_Training (2).ipynb` is kept in sync with this pipeline conceptually; **`training/train_grpo.py`** is the main entry.
|
| 808 |
+
|
| 809 |
+
---
|
| 810 |
+
|
| 811 |
+
## 15. Repository layout (selected)
|
| 812 |
+
|
| 813 |
+
| Path | Role |
|
| 814 |
+
|------|------|
|
| 815 |
+
| `models.py` | Schemas: action, observation, state, `RewardBreakdown` |
|
| 816 |
+
| `openenv.yaml` | OpenEnv manifest: FastAPI `server.app:app` |
|
| 817 |
+
| `server/app.py` | App factory, static UI route, showcase router |
|
| 818 |
+
| `server/FlakeForge_environment.py` | `reset` / `step`, preflight, runner, observations |
|
| 819 |
+
| `server/reward.py` | Verifiable reward and breakdown |
|
| 820 |
+
| `server/oracle_engine.py` | Static oracle over claims and patches |
|
| 821 |
+
| `server/patch_validator.py` | Patch simulation and safety |
|
| 822 |
+
| `server/causal_graph.py` | Causal graph builder |
|
| 823 |
+
| `server/deep_flakiness.py` | AST “deep” signals |
|
| 824 |
+
| `server/docker_runner.py` | pytest / Docker test execution |
|
| 825 |
+
| `agent/unified_agent.py` | JSON-first agent and prompts |
|
| 826 |
+
| `client.py` | Remote HTTP client example |
|
| 827 |
+
| `inference.py` | LLM-driven episodes; logs to `outputs/inference.log`, prints final JSON |
|
| 828 |
+
| `training/` | GRPO and curriculum |
|
| 829 |
+
|
| 830 |
+
|
| 831 |
+
## 16. Contributing
|
| 832 |
+
|
| 833 |
+
- **Issues & PRs:** Bug reports and small, focused PRs are welcome. Please describe **repro** (repo path, test id, command) and, if possible, a **failing** vs **expected** reward or observation.
|
| 834 |
+
- **Scope:** Prefer changes that keep the **environment deterministic** and **reward** tied to **measurable** criteria.
|
| 835 |
+
- **Style:** Match existing typing, docstrings, and module boundaries (`models` / `server` / `agent` / `training`).
|
| 836 |
+
- **Code of conduct:** Be constructive and specific in review; we optimize for **clarity and reproducibility** over sheer benchmark scores.
|
__init__.py
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""FlakeForge V3 — Unified Agent Architecture for Flaky Test Repair.
|
| 2 |
+
|
| 3 |
+
V3 Key Changes:
|
| 4 |
+
- Unified agent: single model generates <think> + <patch> in one pass
|
| 5 |
+
- No LLM judge: 6-signal verifiable reward from execution outcomes
|
| 6 |
+
- Deep flakiness detection: AST-based module cache, fixture, mock, import, async patterns
|
| 7 |
+
- Free-form patches: search/replace hunks instead of hardcoded 7-action space
|
| 8 |
+
- GRPO training: group-relative policy optimization with verifiable reward
|
| 9 |
+
"""
|
| 10 |
+
|
| 11 |
+
from .models import (
|
| 12 |
+
FlakeForgeAction,
|
| 13 |
+
FlakeForgeObservation,
|
| 14 |
+
FlakeForgeState,
|
| 15 |
+
RunRecord,
|
| 16 |
+
PatchRecord,
|
| 17 |
+
RewardBreakdown,
|
| 18 |
+
ROOT_CAUSE_TYPES,
|
| 19 |
+
failure_mode_entropy,
|
| 20 |
+
# Backward compatible aliases
|
| 21 |
+
FlakeforgeAction,
|
| 22 |
+
FlakeforgeObservation,
|
| 23 |
+
)
|
| 24 |
+
|
| 25 |
+
from .agent.unified_agent import (
|
| 26 |
+
UnifiedFlakeForgeAgent,
|
| 27 |
+
build_unified_prompt,
|
| 28 |
+
extract_think,
|
| 29 |
+
extract_patch,
|
| 30 |
+
extract_category_from_think,
|
| 31 |
+
)
|
| 32 |
+
|
| 33 |
+
from .server.FlakeForge_environment import (
|
| 34 |
+
FlakeForgeEnvironment,
|
| 35 |
+
create_flakeforge_environment,
|
| 36 |
+
)
|
| 37 |
+
|
| 38 |
+
from .server.reward import compute_verifiable_reward
|
| 39 |
+
from .server.patch_applier import apply_search_replace_patch, parse_search_replace_hunks
|
| 40 |
+
from .server.deep_flakiness import build_deep_observation_signals
|
| 41 |
+
|
| 42 |
+
from .client import FlakeForgeClient
|
| 43 |
+
|
| 44 |
+
__all__ = [
|
| 45 |
+
# Models
|
| 46 |
+
"FlakeForgeAction",
|
| 47 |
+
"FlakeForgeObservation",
|
| 48 |
+
"FlakeForgeState",
|
| 49 |
+
"RunRecord",
|
| 50 |
+
"PatchRecord",
|
| 51 |
+
"RewardBreakdown",
|
| 52 |
+
"ROOT_CAUSE_TYPES",
|
| 53 |
+
"failure_mode_entropy",
|
| 54 |
+
"FlakeforgeAction",
|
| 55 |
+
"FlakeforgeObservation",
|
| 56 |
+
# Agent
|
| 57 |
+
"UnifiedFlakeForgeAgent",
|
| 58 |
+
"build_unified_prompt",
|
| 59 |
+
"extract_think",
|
| 60 |
+
"extract_patch",
|
| 61 |
+
"extract_category_from_think",
|
| 62 |
+
# Environment
|
| 63 |
+
"FlakeForgeEnvironment",
|
| 64 |
+
"create_flakeforge_environment",
|
| 65 |
+
# Reward
|
| 66 |
+
"compute_verifiable_reward",
|
| 67 |
+
# Patch
|
| 68 |
+
"apply_search_replace_patch",
|
| 69 |
+
"parse_search_replace_hunks",
|
| 70 |
+
# Detection
|
| 71 |
+
"build_deep_observation_signals",
|
| 72 |
+
# Client
|
| 73 |
+
"FlakeForgeClient",
|
| 74 |
+
]
|
_bench.py
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Quick benchmark: reset + rollout timing with num_runs=20."""
|
| 2 |
+
import time
|
| 3 |
+
import json
|
| 4 |
+
from server.FlakeForge_environment import FlakeForgeEnvironment
|
| 5 |
+
from server.docker_runner import DockerTestRunner
|
| 6 |
+
from models import FlakeForgeAction
|
| 7 |
+
from agent.unified_agent import (
|
| 8 |
+
extract_think, extract_patch,
|
| 9 |
+
extract_category_from_think, extract_confidence_from_think,
|
| 10 |
+
)
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
def bench(repo, test_id, label, num_runs=20):
|
| 14 |
+
print(f"\n=== {label}: {repo.split('/')[-1]} (num_runs={num_runs}) ===")
|
| 15 |
+
runner = DockerTestRunner(repo)
|
| 16 |
+
env = FlakeForgeEnvironment(
|
| 17 |
+
repo_path=repo, test_identifier=test_id,
|
| 18 |
+
max_steps=1, num_runs=num_runs, runner=runner,
|
| 19 |
+
)
|
| 20 |
+
|
| 21 |
+
t0 = time.time()
|
| 22 |
+
obs = env.reset(preflight_quick_runs=5, preflight_confirm_runs=10)
|
| 23 |
+
t_reset = time.time() - t0
|
| 24 |
+
print(f" reset (5+10 preflight): {t_reset:.1f}s")
|
| 25 |
+
print(f" should_train={obs.should_train} baseline_pass_rate={obs.baseline_pass_rate:.2f}")
|
| 26 |
+
if not obs.should_train:
|
| 27 |
+
print(f" REJECTED: {obs.done_reason}")
|
| 28 |
+
return
|
| 29 |
+
|
| 30 |
+
fake = json.dumps({
|
| 31 |
+
"think": {"claims": [{"category": "shared_state", "entity": "x",
|
| 32 |
+
"location": "x.py::f", "polarity": "present",
|
| 33 |
+
"reason": "test"}], "confidence": 0.5},
|
| 34 |
+
"patch": {"hunks": []},
|
| 35 |
+
})
|
| 36 |
+
action = FlakeForgeAction(
|
| 37 |
+
raw_response=fake,
|
| 38 |
+
think_text=extract_think(fake),
|
| 39 |
+
patch_text=extract_patch(fake),
|
| 40 |
+
predicted_category=extract_category_from_think(extract_think(fake)),
|
| 41 |
+
predicted_confidence=extract_confidence_from_think(extract_think(fake)),
|
| 42 |
+
)
|
| 43 |
+
|
| 44 |
+
t1 = time.time()
|
| 45 |
+
env.reset(preflight_quick_runs=1, preflight_confirm_runs=1)
|
| 46 |
+
t_fast = time.time() - t1
|
| 47 |
+
|
| 48 |
+
t2 = time.time()
|
| 49 |
+
step_obs = env.step(action)
|
| 50 |
+
t_step = time.time() - t2
|
| 51 |
+
|
| 52 |
+
rollout = t_fast + t_step
|
| 53 |
+
print(f" fast_reset (1+1): {t_fast:.1f}s")
|
| 54 |
+
print(f" env.step ({num_runs} pytest runs): {t_step:.1f}s")
|
| 55 |
+
print(f" reward={step_obs.reward:.3f} pass_rate={step_obs.current_pass_rate:.2f}")
|
| 56 |
+
print(f" ---")
|
| 57 |
+
print(f" 1 rollout = {rollout:.1f}s")
|
| 58 |
+
print(f" G=3 rollouts (sequential per repo) = {3 * rollout:.1f}s")
|
| 59 |
+
print(f" full step (4 repos parallel): ~{t_reset:.0f}s reset + {3 * rollout:.0f}s rollouts = {t_reset + 3 * rollout:.0f}s")
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
bench("seed_repos/idoft/ljson__test_unique_check",
|
| 63 |
+
"tests/test_ljson.py::test_unique_check", "EASY-1")
|
| 64 |
+
bench("seed_repos/idoft/bottle-neck__test_router_mount_pass",
|
| 65 |
+
"test/test_routing.py::test_router_mount_pass", "EASY-2")
|
_curate_and_install.py
ADDED
|
@@ -0,0 +1,262 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Curate seed_repos/idoft to exactly 50 repos.
|
| 3 |
+
|
| 4 |
+
## Quick install cells (Colab / local)
|
| 5 |
+
|
| 6 |
+
If you're running this in **Colab** (recommended for quick bootstraps), run:
|
| 7 |
+
|
| 8 |
+
```bash
|
| 9 |
+
!python -m pip install -U pip setuptools wheel
|
| 10 |
+
!python -m pip install -r training-requirements.txt
|
| 11 |
+
```
|
| 12 |
+
|
| 13 |
+
If you're running locally, first activate your venv, then:
|
| 14 |
+
|
| 15 |
+
```bash
|
| 16 |
+
python -m pip install -U pip setuptools wheel
|
| 17 |
+
python -m pip install -r training-requirements.txt
|
| 18 |
+
```
|
| 19 |
+
|
| 20 |
+
Selection criteria:
|
| 21 |
+
- 17 easy / 17 medium / 16 hard
|
| 22 |
+
- Max category diversity across RESOURCE_LEAK, SHARED_STATE, ORDER_DEPENDENCY,
|
| 23 |
+
TIMING, RACE_CONDITION, NETWORK, NONDETERMINISM, ENVIRONMENT_SENSITIVE
|
| 24 |
+
- Prefer repos with fewer / lighter dependencies
|
| 25 |
+
- Avoid: Apache Beam (massive), keras (heavy ML), robot-arm hardware repos,
|
| 26 |
+
repos needing live network or API credentials, heavy monorepos like demisto-sdk
|
| 27 |
+
- Avoid 3+ slugs from the same upstream project
|
| 28 |
+
"""
|
| 29 |
+
from __future__ import annotations
|
| 30 |
+
import json
|
| 31 |
+
import os
|
| 32 |
+
import shutil
|
| 33 |
+
import stat
|
| 34 |
+
import subprocess
|
| 35 |
+
import sys
|
| 36 |
+
from pathlib import Path
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
def _force_remove(func, path, _exc_info):
|
| 40 |
+
"""onerror handler: clear read-only flag then retry (needed for .git on Windows)."""
|
| 41 |
+
try:
|
| 42 |
+
os.chmod(path, stat.S_IWRITE)
|
| 43 |
+
func(path)
|
| 44 |
+
except Exception:
|
| 45 |
+
pass
|
| 46 |
+
|
| 47 |
+
# ─────────────────────────────────────────────────────────────────
|
| 48 |
+
# THE 50 KEEPERS
|
| 49 |
+
# ─────────────────────────────────────────────────────────────────
|
| 50 |
+
KEEP: set[str] = {
|
| 51 |
+
# ── EASY (17) ──────────────────────────────────────────────
|
| 52 |
+
"api-python__test_query_no_api_key", # SHARED_STATE
|
| 53 |
+
"bottle-neck__test_router_mount_pass", # ORDER_DEPENDENCY
|
| 54 |
+
"codespell__test_dictionary_looping", # ORDER_DEPENDENCY (stdlib only)
|
| 55 |
+
"devtracker__test_end_time_total", # RESOURCE_LEAK
|
| 56 |
+
"expstock__test_append_param", # SHARED_STATE
|
| 57 |
+
"jsonextra__test_disable_rex", # SHARED_STATE
|
| 58 |
+
"ljson__test_unique_check", # RESOURCE_LEAK
|
| 59 |
+
"mdutils__test_create_md_file", # ORDER_DEPENDENCY
|
| 60 |
+
"observed__test_discard", # RESOURCE_LEAK
|
| 61 |
+
"parso__test_permission_error", # RESOURCE_LEAK (parso = light)
|
| 62 |
+
"python-fs__test_mkdir", # RESOURCE_LEAK
|
| 63 |
+
"python-fs__test_rename_directory", # RESOURCE_LEAK
|
| 64 |
+
"redisqueue__test_mock_queue_connection", # SHARED_STATE
|
| 65 |
+
"redisqueue__test_mock_queue_put_get", # ORDER_DEPENDENCY
|
| 66 |
+
"stats_arrays__test_random_variables", # NONDETERMINISM (numpy)
|
| 67 |
+
"typeguard__test_check_call_args", # ORDER_DEPENDENCY
|
| 68 |
+
"Utter-More__test_ibu_aut", # RESOURCE_LEAK
|
| 69 |
+
|
| 70 |
+
# ── MEDIUM (17) ────────────────────────────────────────────
|
| 71 |
+
"accessify__test_implements_no_implementat", # ORDER_DEPENDENCY
|
| 72 |
+
"aiopylimit__test_exception", # TIMING
|
| 73 |
+
"aiotasks__test_memory_delay_add_task_non", # TIMING
|
| 74 |
+
"cloudnetpy__test_fix_old_data_2", # SHARED_STATE
|
| 75 |
+
"cloudnetpy__test_l2_norm", # NONDETERMINISM (numpy)
|
| 76 |
+
"coinbase-commerce-python__test_create", # ORDER_DEPENDENCY
|
| 77 |
+
"confight__test_it_should_load_and_merge_", # ORDER_DEPENDENCY
|
| 78 |
+
"devtracker__test_full_report", # TIMING
|
| 79 |
+
"elemental__test_send_request_should_call_", # ORDER_DEPENDENCY
|
| 80 |
+
"fishbase__test_yaml_conf_as_dict_01", # SHARED_STATE
|
| 81 |
+
"krllint__test_rule_with_fix", # ORDER_DEPENDENCY
|
| 82 |
+
"ljson__test_contains", # ORDER_DEPENDENCY
|
| 83 |
+
"logx__test_formatted_output", # ORDER_DEPENDENCY
|
| 84 |
+
"observed__test_callbacks", # ORDER_DEPENDENCY
|
| 85 |
+
"pydash__test_unique_id", # NONDETERMINISM
|
| 86 |
+
"runium__test_processing", # TIMING
|
| 87 |
+
"yunomi__test_count_calls_decorator", # ORDER_DEPENDENCY
|
| 88 |
+
|
| 89 |
+
# ── HARD (16) ──────────────────────────────────────────────
|
| 90 |
+
"aiotasks__test_build_manager_invalid_pre", # TIMING
|
| 91 |
+
"django-beam__test_delete", # SHARED_STATE
|
| 92 |
+
"django-beam__test_list", # SHARED_STATE
|
| 93 |
+
"django-beam__test_list_search", # SHARED_STATE
|
| 94 |
+
"mythx-cli__test_report_json", # ORDER_DEPENDENCY
|
| 95 |
+
"osc-tiny__test_get", # ORDER_DEPENDENCY
|
| 96 |
+
"panamah-sdk-python__test_events", # TIMING
|
| 97 |
+
"panamah-sdk-python__test_recover_failures", # TIMING
|
| 98 |
+
"plcx__test_clientx_context321", # RACE_CONDITION
|
| 99 |
+
"plcx__test_clientx_error", # RACE_CONDITION
|
| 100 |
+
"proxy.py__test_new_socket_connection_ipv", # RACE_CONDITION
|
| 101 |
+
"pybrake__test_celery_integration", # RESOURCE_LEAK
|
| 102 |
+
"rxpy-backpressure__test_on_next_drop_new_message_", # RACE_CONDITION
|
| 103 |
+
"stats_arrays__pretty_close", # NONDETERMINISM
|
| 104 |
+
"tokendito__test_set_okta_password", # ORDER_DEPENDENCY
|
| 105 |
+
"tokendito__test_set_okta_username", # ORDER_DEPENDENCY
|
| 106 |
+
}
|
| 107 |
+
|
| 108 |
+
ROOT = Path("seed_repos/idoft")
|
| 109 |
+
|
| 110 |
+
# ─────────────────────────────────────────────────────────────────
|
| 111 |
+
# Step 1 — delete repos NOT in KEEP
|
| 112 |
+
# ─────────────────────────────────────────────────────────────────
|
| 113 |
+
def curate() -> tuple[list[str], list[str]]:
|
| 114 |
+
kept, deleted = [], []
|
| 115 |
+
for repo_dir in sorted(ROOT.iterdir()):
|
| 116 |
+
if not repo_dir.is_dir():
|
| 117 |
+
continue
|
| 118 |
+
if repo_dir.name in KEEP:
|
| 119 |
+
kept.append(repo_dir.name)
|
| 120 |
+
else:
|
| 121 |
+
print(f" [DELETE] {repo_dir.name}")
|
| 122 |
+
shutil.rmtree(repo_dir, onerror=_force_remove)
|
| 123 |
+
deleted.append(repo_dir.name)
|
| 124 |
+
return kept, deleted
|
| 125 |
+
|
| 126 |
+
|
| 127 |
+
# ─────────────────────────────────────────────────────────────────
|
| 128 |
+
# Step 2 — install deps for each kept repo
|
| 129 |
+
# ─────────────────────────────────────────────────────────────────
|
| 130 |
+
ALWAYS = ["pytest", "pytest-asyncio"]
|
| 131 |
+
|
| 132 |
+
def install_deps(repo_dir: Path) -> dict:
|
| 133 |
+
marker = repo_dir / ".flakeforge_deps_ready"
|
| 134 |
+
if marker.exists():
|
| 135 |
+
return {"slug": repo_dir.name, "status": "already_done"}
|
| 136 |
+
|
| 137 |
+
cmds: list[list[str]] = [
|
| 138 |
+
[sys.executable, "-m", "pip", "install", "-q", "--no-warn-script-location", *ALWAYS],
|
| 139 |
+
]
|
| 140 |
+
|
| 141 |
+
req = repo_dir / "requirements.txt"
|
| 142 |
+
req_test = repo_dir / "requirements-test.txt"
|
| 143 |
+
req_dev = repo_dir / "requirements-dev.txt"
|
| 144 |
+
|
| 145 |
+
# Install requirements files only. NEVER `pip install -e .` on seed
|
| 146 |
+
# repos — editable installs pollute the venv with cross-repo packages,
|
| 147 |
+
# causing massive import contamination between unrelated projects.
|
| 148 |
+
if req.exists():
|
| 149 |
+
cmds.append([sys.executable, "-m", "pip", "install", "-q",
|
| 150 |
+
"--no-warn-script-location", "-r", "requirements.txt"])
|
| 151 |
+
if req_test.exists():
|
| 152 |
+
cmds.append([sys.executable, "-m", "pip", "install", "-q",
|
| 153 |
+
"--no-warn-script-location", "-r", "requirements-test.txt"])
|
| 154 |
+
if req_dev.exists():
|
| 155 |
+
cmds.append([sys.executable, "-m", "pip", "install", "-q",
|
| 156 |
+
"--no-warn-script-location", "-r", "requirements-dev.txt"])
|
| 157 |
+
|
| 158 |
+
errors = []
|
| 159 |
+
for cmd in cmds:
|
| 160 |
+
print(f" $ {' '.join(cmd[-4:])}")
|
| 161 |
+
try:
|
| 162 |
+
proc = subprocess.run(
|
| 163 |
+
cmd,
|
| 164 |
+
capture_output=True,
|
| 165 |
+
text=True,
|
| 166 |
+
cwd=repo_dir,
|
| 167 |
+
timeout=180,
|
| 168 |
+
)
|
| 169 |
+
if proc.returncode != 0:
|
| 170 |
+
combined = (proc.stderr or "") + (proc.stdout or "")
|
| 171 |
+
# Ignore exit=1 that is solely a pip "new release" notice
|
| 172 |
+
real_error = "\n".join(
|
| 173 |
+
l for l in combined.splitlines()
|
| 174 |
+
if not l.startswith("[notice]") and l.strip()
|
| 175 |
+
)
|
| 176 |
+
if not real_error:
|
| 177 |
+
continue
|
| 178 |
+
snippet = real_error[:400].strip()
|
| 179 |
+
errors.append(snippet)
|
| 180 |
+
print(f" [WARN] exit={proc.returncode}: {snippet[:120]}")
|
| 181 |
+
except subprocess.TimeoutExpired:
|
| 182 |
+
errors.append("timeout")
|
| 183 |
+
print(f" [WARN] install timed out after 180s")
|
| 184 |
+
except Exception as exc:
|
| 185 |
+
errors.append(str(exc))
|
| 186 |
+
print(f" [WARN] {exc}")
|
| 187 |
+
|
| 188 |
+
if not errors:
|
| 189 |
+
marker.write_text("ok\n", encoding="utf-8")
|
| 190 |
+
return {"slug": repo_dir.name, "status": "ok"}
|
| 191 |
+
return {"slug": repo_dir.name, "status": "partial_errors", "errors": errors}
|
| 192 |
+
|
| 193 |
+
|
| 194 |
+
# ─────────────────────────────────────────────────────────────────
|
| 195 |
+
# Step 3 — ensure manifest has flaky_test_path + difficulty
|
| 196 |
+
# ─────────────────────────────────────────────────────────────────
|
| 197 |
+
def ensure_manifest_fields() -> list[str]:
|
| 198 |
+
from build_idoft_dataset import REPOS, _slug
|
| 199 |
+
spec_by_slug = {_slug(s.repo_url, s.test_path): s for s in REPOS}
|
| 200 |
+
fixed = []
|
| 201 |
+
for repo_dir in sorted(ROOT.iterdir()):
|
| 202 |
+
if not repo_dir.is_dir():
|
| 203 |
+
continue
|
| 204 |
+
mf = repo_dir / "flake_manifest.json"
|
| 205 |
+
if not mf.exists():
|
| 206 |
+
continue
|
| 207 |
+
data = json.loads(mf.read_text(encoding="utf-8"))
|
| 208 |
+
spec = spec_by_slug.get(repo_dir.name)
|
| 209 |
+
changed = False
|
| 210 |
+
if not data.get("flaky_test_path") and spec:
|
| 211 |
+
data["flaky_test_path"] = spec.test_path
|
| 212 |
+
changed = True
|
| 213 |
+
if not data.get("test_identifier") and spec:
|
| 214 |
+
data["test_identifier"] = spec.test_path
|
| 215 |
+
changed = True
|
| 216 |
+
if not data.get("difficulty") and spec:
|
| 217 |
+
data["difficulty"] = spec.difficulty
|
| 218 |
+
changed = True
|
| 219 |
+
if changed:
|
| 220 |
+
mf.write_text(json.dumps(data, indent=2), encoding="utf-8")
|
| 221 |
+
fixed.append(repo_dir.name)
|
| 222 |
+
return fixed
|
| 223 |
+
|
| 224 |
+
|
| 225 |
+
# ─────────────────────────────────────────────────────────────────
|
| 226 |
+
# Main
|
| 227 |
+
# ─────────────────────────────────────────────────────────────────
|
| 228 |
+
if __name__ == "__main__":
|
| 229 |
+
print("=" * 70)
|
| 230 |
+
print("STEP 1 — curating to 50 repos")
|
| 231 |
+
print("=" * 70)
|
| 232 |
+
kept, deleted = curate()
|
| 233 |
+
print(f"\nKept: {len(kept)} Deleted: {len(deleted)}")
|
| 234 |
+
|
| 235 |
+
print("\n" + "=" * 70)
|
| 236 |
+
print("STEP 2 — fixing manifest fields (flaky_test_path, difficulty)")
|
| 237 |
+
print("=" * 70)
|
| 238 |
+
fixed = ensure_manifest_fields()
|
| 239 |
+
print(f"Manifests updated: {len(fixed)}")
|
| 240 |
+
|
| 241 |
+
print("\n" + "=" * 70)
|
| 242 |
+
print("STEP 3 — installing deps for each kept repo")
|
| 243 |
+
print("=" * 70)
|
| 244 |
+
results = []
|
| 245 |
+
for slug in sorted(KEEP):
|
| 246 |
+
repo_dir = ROOT / slug
|
| 247 |
+
if not repo_dir.exists():
|
| 248 |
+
print(f"\n [MISSING] {slug} — skipping")
|
| 249 |
+
continue
|
| 250 |
+
print(f"\n[{slug}]")
|
| 251 |
+
r = install_deps(repo_dir)
|
| 252 |
+
results.append(r)
|
| 253 |
+
print(f" -> {r['status']}")
|
| 254 |
+
|
| 255 |
+
ok = [r for r in results if r["status"] in ("ok", "already_done")]
|
| 256 |
+
errors = [r for r in results if r["status"] == "partial_errors"]
|
| 257 |
+
print("\n" + "=" * 70)
|
| 258 |
+
print(f"DONE. ok={len(ok)} partial_errors={len(errors)}")
|
| 259 |
+
if errors:
|
| 260 |
+
print("Repos with partial install errors:")
|
| 261 |
+
for r in errors:
|
| 262 |
+
print(f" {r['slug']}: {r.get('errors', [])[:1]}")
|
_ff_diag.txt
ADDED
|
Binary file (2.09 kB). View file
|
|
|
_tmp_inventory.py
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import json, pathlib, collections
|
| 2 |
+
|
| 3 |
+
root = pathlib.Path("seed_repos/idoft")
|
| 4 |
+
rows = []
|
| 5 |
+
for p in sorted(root.glob("*/flake_manifest.json")):
|
| 6 |
+
try:
|
| 7 |
+
m = json.loads(p.read_text(encoding="utf-8"))
|
| 8 |
+
rows.append({
|
| 9 |
+
"slug": p.parent.name,
|
| 10 |
+
"difficulty": m.get("difficulty", "unknown"),
|
| 11 |
+
"flake_category": m.get("flake_category", "UNKNOWN"),
|
| 12 |
+
"has_test": bool(m.get("flaky_test_path") or m.get("test_identifier")),
|
| 13 |
+
})
|
| 14 |
+
except Exception as e:
|
| 15 |
+
print("BAD", p.parent.name, e)
|
| 16 |
+
|
| 17 |
+
by_diff = collections.Counter(r["difficulty"] for r in rows)
|
| 18 |
+
by_cat = collections.Counter(r["flake_category"] for r in rows)
|
| 19 |
+
print(f"Total: {len(rows)}")
|
| 20 |
+
print("by_difficulty:", dict(by_diff))
|
| 21 |
+
print("by_category:", dict(by_cat))
|
| 22 |
+
|
| 23 |
+
for diff in ("easy", "medium", "hard", "unknown"):
|
| 24 |
+
items = [r for r in rows if r["difficulty"] == diff]
|
| 25 |
+
if not items:
|
| 26 |
+
continue
|
| 27 |
+
print(f"\n--- {diff} ({len(items)}) ---")
|
| 28 |
+
for r in items:
|
| 29 |
+
print(f" {r['slug']} | {r['flake_category']} | has_test={r['has_test']}")
|
agent/__init__.py
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""FlakeForge V3 Agent — unified agent architecture."""
|
| 2 |
+
|
| 3 |
+
from .unified_agent import (
|
| 4 |
+
UnifiedFlakeForgeAgent,
|
| 5 |
+
build_unified_prompt,
|
| 6 |
+
extract_think,
|
| 7 |
+
extract_patch,
|
| 8 |
+
extract_category_from_think,
|
| 9 |
+
extract_confidence_from_think,
|
| 10 |
+
infer_category_from_patch,
|
| 11 |
+
)
|
| 12 |
+
|
| 13 |
+
from .observation_utils import build_observation_from_state, summarize_observation
|
| 14 |
+
|
| 15 |
+
__all__ = [
|
| 16 |
+
"UnifiedFlakeForgeAgent",
|
| 17 |
+
"build_unified_prompt",
|
| 18 |
+
"extract_think",
|
| 19 |
+
"extract_patch",
|
| 20 |
+
"extract_category_from_think",
|
| 21 |
+
"extract_confidence_from_think",
|
| 22 |
+
"infer_category_from_patch",
|
| 23 |
+
"build_observation_from_state",
|
| 24 |
+
"summarize_observation",
|
| 25 |
+
]
|
agent/observation_utils.py
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""V3 Observation Utilities — builds observations from deep flakiness signals.
|
| 2 |
+
|
| 3 |
+
Changes from V2:
|
| 4 |
+
- No hypothesis fields
|
| 5 |
+
- Deep flakiness signals integrated
|
| 6 |
+
- Causal frontier extraction
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
from __future__ import annotations
|
| 10 |
+
|
| 11 |
+
from typing import Any, Dict, List, Optional
|
| 12 |
+
|
| 13 |
+
try:
|
| 14 |
+
from models import FlakeForgeObservation, RunRecord, PatchRecord
|
| 15 |
+
except ImportError:
|
| 16 |
+
from ..models import FlakeForgeObservation, RunRecord, PatchRecord
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
def build_observation_from_state(state: Any) -> FlakeForgeObservation:
|
| 20 |
+
"""Build a V3 observation from an EpisodeState object.
|
| 21 |
+
|
| 22 |
+
This utility bridges the state → observation gap when the state
|
| 23 |
+
object is from a different context (e.g., deserialized from JSON).
|
| 24 |
+
"""
|
| 25 |
+
import math
|
| 26 |
+
|
| 27 |
+
durations = [r.duration_ms for r in (state.run_history or [])[-20:]]
|
| 28 |
+
dur_mean = sum(durations) / max(len(durations), 1) if durations else 0
|
| 29 |
+
dur_std = (
|
| 30 |
+
math.sqrt(sum((d - dur_mean) ** 2 for d in durations) / max(len(durations), 1))
|
| 31 |
+
if durations else 0
|
| 32 |
+
)
|
| 33 |
+
|
| 34 |
+
return FlakeForgeObservation(
|
| 35 |
+
episode_id=state.episode_id,
|
| 36 |
+
test_identifier=state.test_identifier,
|
| 37 |
+
step=state.step_count,
|
| 38 |
+
steps_remaining=state.steps_remaining,
|
| 39 |
+
test_function_source=state.current_test_source,
|
| 40 |
+
source_under_test=state.current_source_under_test,
|
| 41 |
+
run_history=state.run_history[-20:] if state.run_history else [],
|
| 42 |
+
current_pass_rate=state.current_pass_rate,
|
| 43 |
+
baseline_pass_rate=state.baseline_pass_rate,
|
| 44 |
+
patches_applied=state.patches_applied or [],
|
| 45 |
+
total_diff_lines=state.total_diff_lines,
|
| 46 |
+
# Deep signals
|
| 47 |
+
module_cache_violations=state.module_cache_violations or [],
|
| 48 |
+
fixture_scope_risks=state.fixture_scope_risks or [],
|
| 49 |
+
mock_residue_sites=state.mock_residue_sites or [],
|
| 50 |
+
import_side_effect_files=state.import_side_effect_files or [],
|
| 51 |
+
async_contamination_alive=state.async_contamination_alive or False,
|
| 52 |
+
# Causal
|
| 53 |
+
failure_frontier=state.failure_frontier or "",
|
| 54 |
+
call_chain_to_frontier=state.call_chain_to_frontier or [],
|
| 55 |
+
boundary_crossings=state.boundary_crossings or [],
|
| 56 |
+
# iDFlakies
|
| 57 |
+
order_dependency_detected=state.order_dependency_detected or False,
|
| 58 |
+
infrastructure_sensitive=state.infrastructure_sensitive or False,
|
| 59 |
+
# Graph
|
| 60 |
+
causal_graph=state.causal_graph,
|
| 61 |
+
causal_hints=state.causal_hints or [],
|
| 62 |
+
# Failure
|
| 63 |
+
failing_stack_trace=state.failing_stack_trace or "",
|
| 64 |
+
duration_fingerprint={"mean": dur_mean, "std": dur_std},
|
| 65 |
+
# Context
|
| 66 |
+
last_think_text=state.last_think_text or "",
|
| 67 |
+
last_patch_text=state.last_patch_text or "",
|
| 68 |
+
last_reward=state.last_reward or 0.0,
|
| 69 |
+
file_tree=state.file_tree or [],
|
| 70 |
+
)
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
def summarize_observation(obs: FlakeForgeObservation) -> Dict[str, Any]:
|
| 74 |
+
"""Create a compact summary of an observation for logging/debugging."""
|
| 75 |
+
return {
|
| 76 |
+
"test": obs.test_identifier,
|
| 77 |
+
"step": f"{obs.step}/{obs.step + obs.steps_remaining}",
|
| 78 |
+
"pass_rate": f"{obs.current_pass_rate:.2f}",
|
| 79 |
+
"baseline": f"{obs.baseline_pass_rate:.2f}",
|
| 80 |
+
"patches_applied": len(obs.patches_applied),
|
| 81 |
+
"deep_signals": {
|
| 82 |
+
"cache_violations": len(obs.module_cache_violations),
|
| 83 |
+
"fixture_risks": len(obs.fixture_scope_risks),
|
| 84 |
+
"mock_residue": len(obs.mock_residue_sites),
|
| 85 |
+
"import_effects": len(obs.import_side_effect_files),
|
| 86 |
+
"async_alive": obs.async_contamination_alive,
|
| 87 |
+
},
|
| 88 |
+
"causal": {
|
| 89 |
+
"frontier": obs.failure_frontier[:50] if obs.failure_frontier else "",
|
| 90 |
+
"chain_depth": len(obs.call_chain_to_frontier),
|
| 91 |
+
"boundaries": len(obs.boundary_crossings),
|
| 92 |
+
},
|
| 93 |
+
"order_dep": obs.order_dependency_detected,
|
| 94 |
+
"infra_sensitive": obs.infrastructure_sensitive,
|
| 95 |
+
}
|
agent/unified_agent.py
ADDED
|
@@ -0,0 +1,739 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Unified FlakeForge Agent — single model, single forward pass producing JSON."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import json
|
| 6 |
+
import re
|
| 7 |
+
from typing import Any, Dict, List, Optional, Protocol
|
| 8 |
+
|
| 9 |
+
try:
|
| 10 |
+
from models import (
|
| 11 |
+
FlakeForgeAction, FlakeForgeObservation,
|
| 12 |
+
StructuredThink, ThinkClaim,
|
| 13 |
+
StructuredPatch, PatchHunk,
|
| 14 |
+
)
|
| 15 |
+
except ImportError:
|
| 16 |
+
from ..models import (
|
| 17 |
+
FlakeForgeAction, FlakeForgeObservation,
|
| 18 |
+
StructuredThink, ThinkClaim,
|
| 19 |
+
StructuredPatch, PatchHunk,
|
| 20 |
+
)
|
| 21 |
+
|
| 22 |
+
try:
|
| 23 |
+
from utils.logger import get_logger
|
| 24 |
+
except ImportError:
|
| 25 |
+
try:
|
| 26 |
+
from ..utils.logger import get_logger
|
| 27 |
+
except ImportError:
|
| 28 |
+
import logging
|
| 29 |
+
get_logger = lambda n, **kw: logging.getLogger(n)
|
| 30 |
+
|
| 31 |
+
logger = get_logger(__name__)
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
class ModelBackend(Protocol):
|
| 35 |
+
def generate(self, prompt: str, *, system_prompt: str) -> str: ...
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
UNIFIED_SYSTEM_PROMPT = """You are FlakeForge, a debugging agent that fixes flaky Python tests.
|
| 39 |
+
|
| 40 |
+
Reply with ONE JSON object. No markdown, no XML, no commentary.
|
| 41 |
+
|
| 42 |
+
Shape (single-line example, no embedded newlines):
|
| 43 |
+
{"think":{"claims":[{"category":"async_wait","entity":"asyncio.wait_for","location":"source.py::fetch","polarity":"present","reason":"timeout=0.5 too small for slow runs"}],"confidence":0.85},"patch":{"hunks":[{"file":"source.py","search":"timeout=0.5","replace":"timeout=5.0"}]}}
|
| 44 |
+
|
| 45 |
+
CATEGORIES (pick exactly ONE per claim):
|
| 46 |
+
async_wait, concurrency, test_order_dependency, resource_leak, shared_state,
|
| 47 |
+
network, platform_dependency, nondeterminism, import_side_effect,
|
| 48 |
+
module_cache_pollution, fixture_scope_leak, mock_residue, unknown.
|
| 49 |
+
|
| 50 |
+
PATCH RULES (read carefully — failing these breaks the run):
|
| 51 |
+
1. "search" must be ONE single line copied VERBATIM from SOURCE UNDER TEST.
|
| 52 |
+
- Same leading spaces. No tabs. No trailing spaces.
|
| 53 |
+
- Pick a line that is unique in the file (a distinctive expression or assignment).
|
| 54 |
+
2. Do NOT put line breaks inside "search" or "replace". No "\\n" sequences.
|
| 55 |
+
If you need a multi-line edit, pick the SHORTEST single anchor line and
|
| 56 |
+
express the whole new block in "replace" using JSON's standard "\\n" escape
|
| 57 |
+
ONLY when truly necessary (prefer single-line replacements).
|
| 58 |
+
3. "replace" must keep the SAME leading indentation as the search line.
|
| 59 |
+
4. Inside JSON strings escape only what JSON requires:
|
| 60 |
+
" becomes \\"
|
| 61 |
+
\\ becomes \\\\
|
| 62 |
+
Never invent extra backslashes. Never add stray commas after backslashes.
|
| 63 |
+
5. Prefer the smallest fix. Never add sleep(), retry decorators, or @pytest.mark.skip.
|
| 64 |
+
6. Patch source.py, not the test file, unless the bug is in the test itself.
|
| 65 |
+
7. If you cannot find a clear fix, return:
|
| 66 |
+
{"think":{"claims":[{"category":"unknown","entity":"","location":"","polarity":"present","reason":"need more evidence"}],"confidence":0.2},"patch":{"hunks":[]}}
|
| 67 |
+
|
| 68 |
+
NEVER:
|
| 69 |
+
- Wrap the JSON in ```json ... ``` fences.
|
| 70 |
+
- Emit <think> or <patch> tags.
|
| 71 |
+
- Add prose before or after the JSON.
|
| 72 |
+
- Repeat a category that already failed twice in the run history.
|
| 73 |
+
"""
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
CATEGORY_CHEATSHEET = (
|
| 77 |
+
"=== HOW TO PICK A CATEGORY (read RECENT RUNS / LAST FAILURE first) ===\n"
|
| 78 |
+
"- TimeoutError, hang, slow run, asyncio.wait_for / await -> async_wait\n"
|
| 79 |
+
"- random.random(), time.time(), datetime.now(), shuffle -> nondeterminism\n"
|
| 80 |
+
"- threads, Lock/Event/Queue, race messages, queue_full -> concurrency\n"
|
| 81 |
+
"- globals reused across tests, leak symptoms after second run -> shared_state\n"
|
| 82 |
+
"- requests/httpx/socket flakes, ConnectionError -> network\n"
|
| 83 |
+
"- @pytest.fixture(scope=session/module) with mutable state -> fixture_scope_leak\n"
|
| 84 |
+
"- @lru_cache, sys.modules reuse, cache decorators -> module_cache_pollution\n"
|
| 85 |
+
"- top-level side effects at import time -> import_side_effect\n"
|
| 86 |
+
"- patch() / monkeypatch not torn down -> mock_residue\n"
|
| 87 |
+
"- only fails when run after another test -> test_order_dependency\n"
|
| 88 |
+
"- Linux-only / Windows-only / freeing socket ports -> platform_dependency\n"
|
| 89 |
+
"- file/socket/handle not closed -> resource_leak\n"
|
| 90 |
+
"- nothing matches confidently -> unknown"
|
| 91 |
+
)
|
| 92 |
+
|
| 93 |
+
|
| 94 |
+
def _last_attempt_diagnosis(observation: FlakeForgeObservation) -> List[str]:
|
| 95 |
+
"""Summarise the most recent step's outcome in one short block.
|
| 96 |
+
|
| 97 |
+
The 7B model needs an explicit verdict so it can decide whether to keep
|
| 98 |
+
the same hypothesis (clean failure / unapplied patch) or switch to a new
|
| 99 |
+
one (regression / repeated dead-end).
|
| 100 |
+
"""
|
| 101 |
+
history = observation.think_history or []
|
| 102 |
+
patches = observation.patches_applied or []
|
| 103 |
+
|
| 104 |
+
if not history and not patches:
|
| 105 |
+
return []
|
| 106 |
+
|
| 107 |
+
last_think = history[-1] if history else {}
|
| 108 |
+
last_patch = patches[-1] if patches else None
|
| 109 |
+
|
| 110 |
+
cats = last_think.get("categories") or []
|
| 111 |
+
primary_cat = cats[0] if cats else "unknown"
|
| 112 |
+
entities = last_think.get("entities") or []
|
| 113 |
+
pr_after = last_think.get("pass_rate_after", observation.current_pass_rate)
|
| 114 |
+
pr_before = observation.baseline_pass_rate
|
| 115 |
+
reward = last_think.get("reward", observation.last_reward)
|
| 116 |
+
|
| 117 |
+
applied = bool(last_patch and last_patch.applied_successfully)
|
| 118 |
+
files = ", ".join(last_patch.target_files) if last_patch else "—"
|
| 119 |
+
|
| 120 |
+
if not applied:
|
| 121 |
+
outcome = "PATCH DID NOT APPLY (search text not found in file)"
|
| 122 |
+
verdict = (
|
| 123 |
+
"Fix this turn: keep the SAME root cause but rewrite \"search\" as ONE shorter, "
|
| 124 |
+
"more distinctive line copied verbatim from SOURCE UNDER TEST."
|
| 125 |
+
)
|
| 126 |
+
elif pr_after < pr_before - 0.05:
|
| 127 |
+
outcome = f"REGRESSION applied {pr_before:.2f} -> {pr_after:.2f}"
|
| 128 |
+
verdict = (
|
| 129 |
+
"The previous category was wrong. Pick a DIFFERENT category this turn "
|
| 130 |
+
"and patch a different line."
|
| 131 |
+
)
|
| 132 |
+
elif pr_after > pr_before + 0.05:
|
| 133 |
+
outcome = f"IMPROVED {pr_before:.2f} -> {pr_after:.2f}"
|
| 134 |
+
verdict = "Keep refining the same category; tighten the patch."
|
| 135 |
+
else:
|
| 136 |
+
outcome = f"NO EFFECT {pr_before:.2f} -> {pr_after:.2f}"
|
| 137 |
+
verdict = (
|
| 138 |
+
"Same category may still be right but the line you patched was not the cause. "
|
| 139 |
+
"Pick a DIFFERENT line, or switch to a related category."
|
| 140 |
+
)
|
| 141 |
+
|
| 142 |
+
lines = [
|
| 143 |
+
f"Hypothesis: {primary_cat}",
|
| 144 |
+
f"Entities: {', '.join(entities[:3]) if entities else '—'}",
|
| 145 |
+
f"Files: {files}",
|
| 146 |
+
f"Outcome: {outcome}",
|
| 147 |
+
f"Reward: {reward:+.3f}",
|
| 148 |
+
f"Verdict: {verdict}",
|
| 149 |
+
]
|
| 150 |
+
return lines
|
| 151 |
+
|
| 152 |
+
|
| 153 |
+
def scenario_hints(observation: FlakeForgeObservation) -> List[str]:
|
| 154 |
+
"""Per-state guidance the model can act on without re-reading everything."""
|
| 155 |
+
history = observation.think_history or []
|
| 156 |
+
if not history:
|
| 157 |
+
return [
|
| 158 |
+
"- This is the FIRST step. Pick the most likely category from RECENT RUNS.",
|
| 159 |
+
"- Patch source.py (not tests/) unless the test itself is buggy.",
|
| 160 |
+
"- Keep \"search\" to ONE distinctive single line from SOURCE UNDER TEST.",
|
| 161 |
+
]
|
| 162 |
+
|
| 163 |
+
cat_counts: Dict[str, int] = {}
|
| 164 |
+
for h in history:
|
| 165 |
+
cats = h.get("categories") or ["unknown"]
|
| 166 |
+
cat_counts[cats[0]] = cat_counts.get(cats[0], 0) + 1
|
| 167 |
+
repeated = [c for c, n in cat_counts.items() if n >= 2]
|
| 168 |
+
|
| 169 |
+
hints: List[str] = []
|
| 170 |
+
if repeated:
|
| 171 |
+
hints.append(
|
| 172 |
+
f"- You already tried {', '.join(sorted(repeated))} more than once with no success. "
|
| 173 |
+
"Do NOT pick those categories again unless you have new evidence."
|
| 174 |
+
)
|
| 175 |
+
hints.extend([
|
| 176 |
+
"- If your previous patch did not apply: shorten \"search\" to ONE distinctive line.",
|
| 177 |
+
"- If pass_rate dropped: the last category was wrong; switch.",
|
| 178 |
+
"- If pass_rate did not move: try a different LINE (still in source.py) or a related category.",
|
| 179 |
+
])
|
| 180 |
+
return hints
|
| 181 |
+
|
| 182 |
+
|
| 183 |
+
UNIFIED_EXAMPLE_JSON = (
|
| 184 |
+
'{"think":{"claims":[{"category":"<one>","entity":"<symbol>",'
|
| 185 |
+
'"location":"source.py::<func>","polarity":"present","reason":"<short>"}],'
|
| 186 |
+
'"confidence":0.0},"patch":{"hunks":[{"file":"source.py",'
|
| 187 |
+
'"search":"<one distinctive line copied from SOURCE UNDER TEST>",'
|
| 188 |
+
'"replace":"<replacement line>"}]}}'
|
| 189 |
+
)
|
| 190 |
+
|
| 191 |
+
|
| 192 |
+
def build_unified_prompt(observation: FlakeForgeObservation) -> str:
|
| 193 |
+
"""Compact prompt designed for small (≤7B) models.
|
| 194 |
+
|
| 195 |
+
Layout (each section is short and bounded):
|
| 196 |
+
1. TASK header — what test, what step, what pass-rate goal.
|
| 197 |
+
2. SOURCE UNDER TEST — the file the model should patch (≤1800 chars).
|
| 198 |
+
3. TEST FUNCTION — the test that is flaky (≤1000 chars).
|
| 199 |
+
4. LAST FAILURE — tail of the most recent failing stack (≤900 chars).
|
| 200 |
+
5. RECENT RUNS — at most 5 most recent run outcomes.
|
| 201 |
+
6. DEEP SIGNALS — single-line summary of static / dynamic alerts.
|
| 202 |
+
7. TARGETING HINTS — top causal hints (≤4).
|
| 203 |
+
8. LAST ATTEMPT — verdict on the previous step (only if any).
|
| 204 |
+
9. SCENARIO GUIDE — what to do this turn given the state above.
|
| 205 |
+
10. CATEGORY HINTS — short cheatsheet to map symptoms -> category.
|
| 206 |
+
11. YOUR TURN — one-line schema reminder, single-line example.
|
| 207 |
+
"""
|
| 208 |
+
parts: List[str] = []
|
| 209 |
+
|
| 210 |
+
parts.append("=== TASK ===")
|
| 211 |
+
parts.append(f"Test: {observation.test_identifier}")
|
| 212 |
+
parts.append(f"Step: {observation.step + 1} of {observation.step + observation.steps_remaining}")
|
| 213 |
+
parts.append(
|
| 214 |
+
f"Pass rate: baseline={observation.baseline_pass_rate:.2f} "
|
| 215 |
+
f"current={observation.current_pass_rate:.2f} goal=1.00"
|
| 216 |
+
)
|
| 217 |
+
parts.append("")
|
| 218 |
+
|
| 219 |
+
if observation.source_under_test:
|
| 220 |
+
parts.append("=== SOURCE UNDER TEST (PATCH THIS FILE: source.py) ===")
|
| 221 |
+
parts.append(observation.source_under_test[:1800])
|
| 222 |
+
parts.append("")
|
| 223 |
+
|
| 224 |
+
if observation.test_function_source:
|
| 225 |
+
parts.append("=== TEST FUNCTION ===")
|
| 226 |
+
parts.append(observation.test_function_source[:1000])
|
| 227 |
+
parts.append("")
|
| 228 |
+
|
| 229 |
+
if observation.failing_stack_trace:
|
| 230 |
+
parts.append("=== LAST FAILURE (tail) ===")
|
| 231 |
+
parts.append(observation.failing_stack_trace[-900:])
|
| 232 |
+
parts.append("")
|
| 233 |
+
|
| 234 |
+
if observation.run_history:
|
| 235 |
+
parts.append("=== RECENT RUNS (last 5) ===")
|
| 236 |
+
for r in observation.run_history[-5:]:
|
| 237 |
+
verdict = "PASS" if r.passed else f"FAIL({r.error_type or 'err'})"
|
| 238 |
+
line = f"- {verdict} {r.duration_ms}ms"
|
| 239 |
+
if not r.passed and r.error_message:
|
| 240 |
+
line += f" msg={r.error_message[:80]}"
|
| 241 |
+
parts.append(line)
|
| 242 |
+
parts.append("")
|
| 243 |
+
|
| 244 |
+
deep: List[str] = []
|
| 245 |
+
if observation.async_contamination_alive:
|
| 246 |
+
deep.append("async tasks/threads survived past test boundary")
|
| 247 |
+
if observation.module_cache_violations:
|
| 248 |
+
deep.append(f"module-cache hot spots: {', '.join(observation.module_cache_violations[:3])}")
|
| 249 |
+
if observation.fixture_scope_risks:
|
| 250 |
+
deep.append(f"fixture-scope risks: {', '.join(observation.fixture_scope_risks[:3])}")
|
| 251 |
+
if observation.mock_residue_sites:
|
| 252 |
+
deep.append(f"mock residue: {', '.join(observation.mock_residue_sites[:3])}")
|
| 253 |
+
if observation.import_side_effect_files:
|
| 254 |
+
deep.append(f"import side effects: {', '.join(observation.import_side_effect_files[:3])}")
|
| 255 |
+
if observation.order_dependency_detected:
|
| 256 |
+
deep.append("test order matters (fails when reordered)")
|
| 257 |
+
if observation.infrastructure_sensitive:
|
| 258 |
+
deep.append("infrastructure-sensitive (fails under resource pressure)")
|
| 259 |
+
if deep:
|
| 260 |
+
parts.append("=== DEEP SIGNALS ===")
|
| 261 |
+
parts.extend(f"- {s}" for s in deep)
|
| 262 |
+
parts.append("")
|
| 263 |
+
|
| 264 |
+
if observation.causal_hints:
|
| 265 |
+
parts.append("=== TARGETING HINTS ===")
|
| 266 |
+
parts.extend(f"- {h}" for h in observation.causal_hints[:4])
|
| 267 |
+
parts.append("")
|
| 268 |
+
|
| 269 |
+
diag = _last_attempt_diagnosis(observation)
|
| 270 |
+
if diag:
|
| 271 |
+
parts.append("=== LAST ATTEMPT ===")
|
| 272 |
+
parts.extend(diag)
|
| 273 |
+
parts.append("")
|
| 274 |
+
|
| 275 |
+
parts.append("=== SCENARIO GUIDE (do this now) ===")
|
| 276 |
+
parts.extend(scenario_hints(observation))
|
| 277 |
+
parts.append("")
|
| 278 |
+
|
| 279 |
+
parts.append(CATEGORY_CHEATSHEET)
|
| 280 |
+
parts.append("")
|
| 281 |
+
|
| 282 |
+
parts.append("=== YOUR TURN ===")
|
| 283 |
+
parts.append("Reply with ONE JSON object. No markdown, no XML, no commentary.")
|
| 284 |
+
parts.append("Single-line example shape (do NOT copy values, just the structure):")
|
| 285 |
+
parts.append(UNIFIED_EXAMPLE_JSON)
|
| 286 |
+
return "\n".join(parts)
|
| 287 |
+
|
| 288 |
+
|
| 289 |
+
def _strip_markdown_fence(text: str) -> str:
|
| 290 |
+
stripped = text.strip()
|
| 291 |
+
m = re.fullmatch(r"```(?:[A-Za-z0-9_-]+)?\s*(.*?)\s*```", stripped, re.DOTALL)
|
| 292 |
+
return m.group(1).strip() if m else stripped
|
| 293 |
+
|
| 294 |
+
|
| 295 |
+
def _extract_json_object_text(text: str) -> str:
|
| 296 |
+
"""Return the first complete JSON object in text, or an empty string."""
|
| 297 |
+
stripped = _strip_markdown_fence(text)
|
| 298 |
+
decoder = json.JSONDecoder()
|
| 299 |
+
for match in re.finditer(r"\{", stripped):
|
| 300 |
+
try:
|
| 301 |
+
_, end = decoder.raw_decode(stripped[match.start():])
|
| 302 |
+
except json.JSONDecodeError:
|
| 303 |
+
continue
|
| 304 |
+
return stripped[match.start(): match.start() + end]
|
| 305 |
+
return ""
|
| 306 |
+
|
| 307 |
+
|
| 308 |
+
def _load_json_object(text: str) -> Optional[Dict[str, Any]]:
|
| 309 |
+
json_text = _extract_json_object_text(text)
|
| 310 |
+
if not json_text:
|
| 311 |
+
return None
|
| 312 |
+
try:
|
| 313 |
+
data = json.loads(json_text)
|
| 314 |
+
except json.JSONDecodeError:
|
| 315 |
+
return None
|
| 316 |
+
return data if isinstance(data, dict) else None
|
| 317 |
+
|
| 318 |
+
|
| 319 |
+
def _normalise_hunk_text(value: str) -> str:
|
| 320 |
+
"""Normalise model hunk strings that are double-escaped JSON text."""
|
| 321 |
+
text = value
|
| 322 |
+
if "\\n" in text and "\n" not in text:
|
| 323 |
+
text = text.replace("\\n", "\n")
|
| 324 |
+
if "\\t" in text and "\t" not in text:
|
| 325 |
+
text = text.replace("\\t", " ")
|
| 326 |
+
if '\\"' in text:
|
| 327 |
+
text = text.replace('\\"', '"')
|
| 328 |
+
text = re.sub(r"\\+[\"']\s*$", "", text)
|
| 329 |
+
return text
|
| 330 |
+
|
| 331 |
+
|
| 332 |
+
def _patch_dict_to_search_replace(patch_data: Any) -> str:
|
| 333 |
+
"""Convert validator-compatible JSON hunk fields to legacy patch text."""
|
| 334 |
+
if not isinstance(patch_data, dict):
|
| 335 |
+
return ""
|
| 336 |
+
hunks = patch_data.get("hunks", [])
|
| 337 |
+
if not isinstance(hunks, list):
|
| 338 |
+
return ""
|
| 339 |
+
|
| 340 |
+
blocks: List[str] = []
|
| 341 |
+
for hunk in hunks:
|
| 342 |
+
if not isinstance(hunk, dict):
|
| 343 |
+
continue
|
| 344 |
+
file_path = str(hunk.get("file") or "").strip().replace("\\", "/")
|
| 345 |
+
search = hunk.get("search")
|
| 346 |
+
replace = hunk.get("replace")
|
| 347 |
+
if not file_path or not isinstance(search, str) or not isinstance(replace, str):
|
| 348 |
+
continue
|
| 349 |
+
|
| 350 |
+
search = _normalise_hunk_text(search)
|
| 351 |
+
replace = _normalise_hunk_text(replace)
|
| 352 |
+
if not search.strip():
|
| 353 |
+
continue
|
| 354 |
+
|
| 355 |
+
blocks.append(
|
| 356 |
+
"\n".join([
|
| 357 |
+
f"--- {file_path}",
|
| 358 |
+
"<<<<<<< SEARCH",
|
| 359 |
+
search.rstrip("\n"),
|
| 360 |
+
"=======",
|
| 361 |
+
replace.rstrip("\n"),
|
| 362 |
+
">>>>>>> REPLACE",
|
| 363 |
+
])
|
| 364 |
+
)
|
| 365 |
+
return "\n\n".join(blocks)
|
| 366 |
+
|
| 367 |
+
|
| 368 |
+
def extract_patch(response: str) -> str:
|
| 369 |
+
"""Extract patch text, preferring the single-object JSON response format."""
|
| 370 |
+
response = _strip_markdown_fence(response)
|
| 371 |
+
|
| 372 |
+
data = _load_json_object(response)
|
| 373 |
+
if isinstance(data, dict) and "patch" in data:
|
| 374 |
+
patch_text = _patch_dict_to_search_replace(data.get("patch"))
|
| 375 |
+
if patch_text:
|
| 376 |
+
return patch_text
|
| 377 |
+
|
| 378 |
+
m = re.search(r"<patch>(.*?)</patch>", response, re.DOTALL)
|
| 379 |
+
if m:
|
| 380 |
+
inner = _strip_markdown_fence(m.group(1))
|
| 381 |
+
data = _load_json_object(inner)
|
| 382 |
+
if isinstance(data, dict):
|
| 383 |
+
patch_text = _patch_dict_to_search_replace(data.get("patch") if "patch" in data else data)
|
| 384 |
+
if patch_text:
|
| 385 |
+
return patch_text
|
| 386 |
+
return inner
|
| 387 |
+
|
| 388 |
+
fenced = re.search(r"```(?:patch|diff|xml)?\s*(.*?)```", response, re.DOTALL | re.IGNORECASE)
|
| 389 |
+
if fenced:
|
| 390 |
+
inner = fenced.group(1).strip()
|
| 391 |
+
pm = re.search(r"<patch>(.*?)</patch>", inner, re.DOTALL)
|
| 392 |
+
if pm:
|
| 393 |
+
patch_inner = _strip_markdown_fence(pm.group(1))
|
| 394 |
+
data = _load_json_object(patch_inner)
|
| 395 |
+
if isinstance(data, dict):
|
| 396 |
+
patch_text = _patch_dict_to_search_replace(data.get("patch") if "patch" in data else data)
|
| 397 |
+
if patch_text:
|
| 398 |
+
return patch_text
|
| 399 |
+
return patch_inner
|
| 400 |
+
return inner
|
| 401 |
+
|
| 402 |
+
hunk_start = response.find("<<<<<<< SEARCH")
|
| 403 |
+
if hunk_start != -1:
|
| 404 |
+
header_start = response.rfind("\n--- ", 0, hunk_start)
|
| 405 |
+
start = header_start + 1 if header_start != -1 else hunk_start
|
| 406 |
+
hunk_end = response.find(">>>>>>> REPLACE", hunk_start)
|
| 407 |
+
if hunk_end != -1:
|
| 408 |
+
return response[start : hunk_end + len(">>>>>>> REPLACE")].strip()
|
| 409 |
+
return ""
|
| 410 |
+
|
| 411 |
+
|
| 412 |
+
_VALID_CATEGORIES = {
|
| 413 |
+
"async_wait", "concurrency", "test_order_dependency", "resource_leak",
|
| 414 |
+
"shared_state", "network", "platform_dependency", "nondeterminism",
|
| 415 |
+
"import_side_effect", "module_cache_pollution", "fixture_scope_leak",
|
| 416 |
+
"mock_residue", "unknown",
|
| 417 |
+
}
|
| 418 |
+
|
| 419 |
+
_NORM_MAP: Dict[str, str] = {
|
| 420 |
+
"async": "async_wait", "asyncwait": "async_wait", "timeout": "async_wait",
|
| 421 |
+
"race": "concurrency", "race_condition": "concurrency", "threading": "concurrency",
|
| 422 |
+
"order": "test_order_dependency", "test_order": "test_order_dependency",
|
| 423 |
+
"order_dependency": "test_order_dependency",
|
| 424 |
+
"resource": "resource_leak", "leak": "resource_leak",
|
| 425 |
+
"shared": "shared_state", "state": "shared_state",
|
| 426 |
+
"connection": "network", "http": "network",
|
| 427 |
+
"platform": "platform_dependency",
|
| 428 |
+
"random": "nondeterminism",
|
| 429 |
+
"import": "import_side_effect",
|
| 430 |
+
"cache": "module_cache_pollution", "module_cache": "module_cache_pollution",
|
| 431 |
+
"fixture": "fixture_scope_leak", "fixture_scope": "fixture_scope_leak",
|
| 432 |
+
"mock": "mock_residue", "monkeypatch": "mock_residue",
|
| 433 |
+
}
|
| 434 |
+
|
| 435 |
+
_ALLOWED_HUNK_KEYS = {
|
| 436 |
+
"hunk_id", "file", "search", "replace", "rationale", "addresses_claim",
|
| 437 |
+
}
|
| 438 |
+
|
| 439 |
+
|
| 440 |
+
def _normalise_category(raw: str) -> str:
|
| 441 |
+
c = raw.lower().strip()
|
| 442 |
+
if c in _VALID_CATEGORIES:
|
| 443 |
+
return c
|
| 444 |
+
return _NORM_MAP.get(c, "unknown")
|
| 445 |
+
|
| 446 |
+
|
| 447 |
+
def _parse_structured_think(think_raw: Any) -> StructuredThink:
|
| 448 |
+
"""Try to parse think_raw as JSON StructuredThink.
|
| 449 |
+
|
| 450 |
+
On any failure: return a StructuredThink with format_penalty=-1.0 and
|
| 451 |
+
whatever claims could be salvaged. Never raises.
|
| 452 |
+
"""
|
| 453 |
+
if isinstance(think_raw, dict):
|
| 454 |
+
data = think_raw
|
| 455 |
+
else:
|
| 456 |
+
data = _load_json_object(str(think_raw))
|
| 457 |
+
if data is None:
|
| 458 |
+
return StructuredThink(format_penalty=-1.0)
|
| 459 |
+
|
| 460 |
+
claims: List[ThinkClaim] = []
|
| 461 |
+
raw_claims = data.get("claims", [])
|
| 462 |
+
|
| 463 |
+
if not isinstance(raw_claims, list):
|
| 464 |
+
return StructuredThink(format_penalty=-1.0)
|
| 465 |
+
|
| 466 |
+
partial_penalty = 0.0
|
| 467 |
+
for i, rc in enumerate(raw_claims):
|
| 468 |
+
if not isinstance(rc, dict):
|
| 469 |
+
partial_penalty = -0.3
|
| 470 |
+
continue
|
| 471 |
+
clean = {k: v for k, v in rc.items() if k in _ALLOWED_CLAIM_KEYS}
|
| 472 |
+
if "category" in clean:
|
| 473 |
+
clean["category"] = _normalise_category(clean["category"])
|
| 474 |
+
if clean.get("polarity") not in ("present", "absent"):
|
| 475 |
+
clean["polarity"] = "present"
|
| 476 |
+
partial_penalty = min(partial_penalty, -0.1)
|
| 477 |
+
clean.setdefault("claim_id", f"c{i+1}")
|
| 478 |
+
clean.setdefault("entity", "")
|
| 479 |
+
clean.setdefault("location", "")
|
| 480 |
+
clean.setdefault("ast_node_type", "")
|
| 481 |
+
clean.setdefault("predicted_effect", "")
|
| 482 |
+
clean.setdefault("reason", "")
|
| 483 |
+
try:
|
| 484 |
+
claims.append(ThinkClaim(**clean))
|
| 485 |
+
except Exception:
|
| 486 |
+
partial_penalty = -0.3
|
| 487 |
+
continue
|
| 488 |
+
|
| 489 |
+
try:
|
| 490 |
+
confidence = float(data.get("confidence", 0.5))
|
| 491 |
+
confidence = max(0.0, min(1.0, confidence))
|
| 492 |
+
except (TypeError, ValueError):
|
| 493 |
+
confidence = 0.5
|
| 494 |
+
partial_penalty = min(partial_penalty, -0.1)
|
| 495 |
+
|
| 496 |
+
if not claims:
|
| 497 |
+
return StructuredThink(confidence=confidence, format_penalty=-1.0)
|
| 498 |
+
|
| 499 |
+
return StructuredThink(claims=claims, confidence=confidence, format_penalty=partial_penalty)
|
| 500 |
+
|
| 501 |
+
|
| 502 |
+
_ALLOWED_CLAIM_KEYS = {
|
| 503 |
+
"claim_id", "category", "entity", "location",
|
| 504 |
+
"ast_node_type", "polarity", "predicted_effect", "reason",
|
| 505 |
+
}
|
| 506 |
+
|
| 507 |
+
|
| 508 |
+
def _parse_structured_patch(patch_raw: Any) -> "StructuredPatch":
|
| 509 |
+
"""Try to parse patch_raw as JSON StructuredPatch.
|
| 510 |
+
|
| 511 |
+
On any failure: return a StructuredPatch with format_penalty=-1.0 and
|
| 512 |
+
whatever hunks could be salvaged. Never raises.
|
| 513 |
+
"""
|
| 514 |
+
if isinstance(patch_raw, dict):
|
| 515 |
+
data = patch_raw
|
| 516 |
+
else:
|
| 517 |
+
data = _load_json_object(str(patch_raw))
|
| 518 |
+
if data is None:
|
| 519 |
+
return StructuredPatch(format_penalty=-1.0)
|
| 520 |
+
|
| 521 |
+
hunks: List[PatchHunk] = []
|
| 522 |
+
raw_hunks = data.get("hunks", [])
|
| 523 |
+
|
| 524 |
+
if not isinstance(raw_hunks, list):
|
| 525 |
+
return StructuredPatch(format_penalty=-1.0)
|
| 526 |
+
|
| 527 |
+
partial_penalty = 0.0
|
| 528 |
+
for i, rh in enumerate(raw_hunks):
|
| 529 |
+
if not isinstance(rh, dict):
|
| 530 |
+
partial_penalty = -0.3
|
| 531 |
+
continue
|
| 532 |
+
clean = {k: v for k, v in rh.items() if k in _ALLOWED_HUNK_KEYS}
|
| 533 |
+
clean.setdefault("hunk_id", f"h{i+1}")
|
| 534 |
+
clean.setdefault("rationale", "")
|
| 535 |
+
clean.setdefault("addresses_claim", "")
|
| 536 |
+
if "file" not in clean or "search" not in clean or "replace" not in clean:
|
| 537 |
+
partial_penalty = min(partial_penalty, -0.3)
|
| 538 |
+
continue
|
| 539 |
+
try:
|
| 540 |
+
hunks.append(PatchHunk(**clean))
|
| 541 |
+
except Exception:
|
| 542 |
+
partial_penalty = -0.3
|
| 543 |
+
continue
|
| 544 |
+
|
| 545 |
+
if not hunks:
|
| 546 |
+
return StructuredPatch(format_penalty=-1.0)
|
| 547 |
+
|
| 548 |
+
return StructuredPatch(hunks=hunks, format_penalty=partial_penalty)
|
| 549 |
+
|
| 550 |
+
|
| 551 |
+
|
| 552 |
+
def extract_think(response: str) -> str:
|
| 553 |
+
"""Extract think JSON/text from the model response."""
|
| 554 |
+
response = _strip_markdown_fence(response)
|
| 555 |
+
data = _load_json_object(response)
|
| 556 |
+
if isinstance(data, dict) and "think" in data:
|
| 557 |
+
return json.dumps(data.get("think"), ensure_ascii=False)
|
| 558 |
+
m = re.search(r"<think>(.*?)</think>", response, re.DOTALL)
|
| 559 |
+
return m.group(1).strip() if m else ""
|
| 560 |
+
|
| 561 |
+
|
| 562 |
+
def extract_category_from_think(think_text: str) -> str:
|
| 563 |
+
"""Extract predicted root cause from structured JSON or legacy free-form text."""
|
| 564 |
+
st = _parse_structured_think(think_text)
|
| 565 |
+
if st.claims and st.format_penalty == 0.0:
|
| 566 |
+
return st.primary_category
|
| 567 |
+
m = re.search(r"Root\s*Cause:\s*(\w+)", think_text, re.IGNORECASE)
|
| 568 |
+
if m:
|
| 569 |
+
return _normalise_category(m.group(1))
|
| 570 |
+
return "unknown"
|
| 571 |
+
|
| 572 |
+
|
| 573 |
+
def extract_confidence_from_think(think_text: str) -> float:
|
| 574 |
+
"""Extract confidence from structured JSON or legacy free-form text."""
|
| 575 |
+
st = _parse_structured_think(think_text)
|
| 576 |
+
if st.claims and st.format_penalty == 0.0:
|
| 577 |
+
return st.confidence
|
| 578 |
+
m = re.search(r"confidence:\s*([\d.]+)", think_text, re.IGNORECASE)
|
| 579 |
+
if m:
|
| 580 |
+
try:
|
| 581 |
+
return max(0.0, min(1.0, float(m.group(1))))
|
| 582 |
+
except ValueError:
|
| 583 |
+
pass
|
| 584 |
+
return 0.5
|
| 585 |
+
|
| 586 |
+
|
| 587 |
+
def infer_category_from_patch(patch_text: str) -> str:
|
| 588 |
+
"""Infer actual fix category from what the patch modifies (keyword scan)."""
|
| 589 |
+
p = patch_text.lower()
|
| 590 |
+
if "timeout" in p or "wait_for" in p:
|
| 591 |
+
return "async_wait"
|
| 592 |
+
if "sleep" in p and "asyncio" in p:
|
| 593 |
+
return "async_wait"
|
| 594 |
+
if "lock" in p or "semaphore" in p or "barrier" in p:
|
| 595 |
+
return "concurrency"
|
| 596 |
+
if "fixture" in p or "teardown" in p or "yield" in p:
|
| 597 |
+
return "fixture_scope_leak"
|
| 598 |
+
if "mock" in p or "monkeypatch" in p or "patch(" in p:
|
| 599 |
+
return "mock_residue"
|
| 600 |
+
if "cache_clear" in p or "lru_cache" in p:
|
| 601 |
+
return "module_cache_pollution"
|
| 602 |
+
if "clear()" in p or "reset" in p:
|
| 603 |
+
return "shared_state"
|
| 604 |
+
if "if __name__" in p or "import" in p:
|
| 605 |
+
return "import_side_effect"
|
| 606 |
+
if "seed(" in p or "random" in p:
|
| 607 |
+
return "nondeterminism"
|
| 608 |
+
return "unknown"
|
| 609 |
+
|
| 610 |
+
|
| 611 |
+
class UnifiedFlakeForgeAgent:
|
| 612 |
+
"""Unified agent — one model, one JSON forward pass with XML fallback."""
|
| 613 |
+
|
| 614 |
+
def __init__(self, backend: ModelBackend) -> None:
|
| 615 |
+
self.backend = backend
|
| 616 |
+
self.system_prompt = UNIFIED_SYSTEM_PROMPT
|
| 617 |
+
|
| 618 |
+
def generate(self, observation: FlakeForgeObservation) -> FlakeForgeAction:
|
| 619 |
+
prompt = build_unified_prompt(observation)
|
| 620 |
+
raw_response = self.backend.generate(prompt, system_prompt=self.system_prompt)
|
| 621 |
+
|
| 622 |
+
def _decode_action(raw: str):
|
| 623 |
+
response = _load_json_object(raw)
|
| 624 |
+
if isinstance(response, dict) and (
|
| 625 |
+
isinstance(response.get("think"), dict)
|
| 626 |
+
or isinstance(response.get("patch"), dict)
|
| 627 |
+
):
|
| 628 |
+
think = json.dumps(response.get("think", {}), ensure_ascii=False)
|
| 629 |
+
parsed_think = _parse_structured_think(response.get("think", {}))
|
| 630 |
+
parsed_patch = _parse_structured_patch(response.get("patch", {}))
|
| 631 |
+
patch = _patch_dict_to_search_replace(response.get("patch", {}))
|
| 632 |
+
else:
|
| 633 |
+
think = extract_think(raw)
|
| 634 |
+
patch = extract_patch(raw)
|
| 635 |
+
parsed_think = _parse_structured_think(think)
|
| 636 |
+
parsed_patch = _parse_structured_patch(patch)
|
| 637 |
+
return response, think, patch, parsed_think, parsed_patch
|
| 638 |
+
|
| 639 |
+
(
|
| 640 |
+
response_obj,
|
| 641 |
+
think_raw,
|
| 642 |
+
patch_raw,
|
| 643 |
+
structured_think,
|
| 644 |
+
structured_patch,
|
| 645 |
+
) = _decode_action(raw_response)
|
| 646 |
+
|
| 647 |
+
# Small models sometimes emit malformed JSON/hunks on the first try.
|
| 648 |
+
# Retry once immediately instead of wasting a full environment step.
|
| 649 |
+
should_retry = (
|
| 650 |
+
response_obj is None
|
| 651 |
+
or structured_patch.format_penalty < 0.0
|
| 652 |
+
or len(structured_patch.hunks) == 0
|
| 653 |
+
or not patch_raw.strip()
|
| 654 |
+
)
|
| 655 |
+
if should_retry:
|
| 656 |
+
logger.warning(
|
| 657 |
+
"[UNIFIED_AGENT] First decode invalid/empty; retrying generation once with strict JSON reminder."
|
| 658 |
+
)
|
| 659 |
+
retry_prompt = (
|
| 660 |
+
prompt
|
| 661 |
+
+ "\n\n=== STRICT RETRY ===\n"
|
| 662 |
+
+ "Your previous reply was not valid JSON or had no usable hunks.\n"
|
| 663 |
+
+ "Common mistakes to AVOID this time:\n"
|
| 664 |
+
+ "- Multi-line \"search\" (use ONE single line copied from SOURCE UNDER TEST).\n"
|
| 665 |
+
+ "- Stray backslashes (\\\\\\\\, \\,, trailing \\). Inside JSON only \\\" and \\\\ are needed.\n"
|
| 666 |
+
+ "- Markdown fences, XML tags, or any text outside the JSON object.\n"
|
| 667 |
+
+ "Reply with EXACTLY one JSON object that matches the shape shown above."
|
| 668 |
+
)
|
| 669 |
+
retry_raw = self.backend.generate(retry_prompt, system_prompt=self.system_prompt)
|
| 670 |
+
(
|
| 671 |
+
retry_obj,
|
| 672 |
+
retry_think,
|
| 673 |
+
retry_patch,
|
| 674 |
+
retry_structured_think,
|
| 675 |
+
retry_structured_patch,
|
| 676 |
+
) = _decode_action(retry_raw)
|
| 677 |
+
retry_better = (
|
| 678 |
+
(retry_obj is not None)
|
| 679 |
+
and (retry_structured_patch.format_penalty == 0.0)
|
| 680 |
+
and (len(retry_structured_patch.hunks) > 0)
|
| 681 |
+
)
|
| 682 |
+
if retry_better:
|
| 683 |
+
raw_response = retry_raw
|
| 684 |
+
response_obj = retry_obj
|
| 685 |
+
think_raw = retry_think
|
| 686 |
+
patch_raw = retry_patch
|
| 687 |
+
structured_think = retry_structured_think
|
| 688 |
+
structured_patch = retry_structured_patch
|
| 689 |
+
|
| 690 |
+
predicted_category = (
|
| 691 |
+
structured_think.primary_category
|
| 692 |
+
if structured_think.claims
|
| 693 |
+
else extract_category_from_think(think_raw)
|
| 694 |
+
)
|
| 695 |
+
predicted_confidence = (
|
| 696 |
+
structured_think.confidence
|
| 697 |
+
if structured_think.claims
|
| 698 |
+
else extract_confidence_from_think(think_raw)
|
| 699 |
+
)
|
| 700 |
+
|
| 701 |
+
if response_obj is None:
|
| 702 |
+
logger.warning(
|
| 703 |
+
"[UNIFIED_AGENT] Model output is not a single JSON object; using tolerant parsing."
|
| 704 |
+
)
|
| 705 |
+
|
| 706 |
+
if structured_think.format_penalty < 0.0:
|
| 707 |
+
logger.warning(
|
| 708 |
+
"[UNIFIED_AGENT] think object is not valid JSON (penalty=%.1f); "
|
| 709 |
+
"falling back to legacy parsing.",
|
| 710 |
+
structured_think.format_penalty,
|
| 711 |
+
)
|
| 712 |
+
|
| 713 |
+
if structured_patch.format_penalty < 0.0:
|
| 714 |
+
logger.warning(
|
| 715 |
+
"[UNIFIED_AGENT] patch object is not valid JSON (penalty=%.1f); "
|
| 716 |
+
"patch will not have structured hunk fields.",
|
| 717 |
+
structured_patch.format_penalty,
|
| 718 |
+
)
|
| 719 |
+
|
| 720 |
+
logger.info(
|
| 721 |
+
"[UNIFIED_AGENT] category=%s confidence=%.2f patch_hunks=%d think_json=%s patch_json=%s",
|
| 722 |
+
predicted_category,
|
| 723 |
+
predicted_confidence,
|
| 724 |
+
len(structured_patch.hunks),
|
| 725 |
+
structured_think.format_penalty == 0.0,
|
| 726 |
+
structured_patch.format_penalty == 0.0,
|
| 727 |
+
)
|
| 728 |
+
|
| 729 |
+
return FlakeForgeAction(
|
| 730 |
+
raw_response=raw_response,
|
| 731 |
+
think_text=think_raw,
|
| 732 |
+
patch_text=patch_raw, # raw fallback
|
| 733 |
+
structured_think=structured_think,
|
| 734 |
+
structured_patch=structured_patch,
|
| 735 |
+
predicted_category=predicted_category,
|
| 736 |
+
predicted_confidence=predicted_confidence,
|
| 737 |
+
action_type="UNIFIED_PATCH",
|
| 738 |
+
parameters={},
|
| 739 |
+
)
|
build_idoft_dataset.py
ADDED
|
@@ -0,0 +1,711 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
FlakeForge — IDoFT 100-repo Training Dataset Builder
|
| 3 |
+
=====================================================
|
| 4 |
+
Hand-curated from the full IDoFT py-data.csv (65 chunks read and analysed).
|
| 5 |
+
Selection criteria:
|
| 6 |
+
- Exactly ONE unique repo+test pair per entry (no accidental duplicates)
|
| 7 |
+
- All 6 IDoFT categories covered: NOD, NIO, OD, OD-Vic, OD-Brit, ID
|
| 8 |
+
- All 6 FlakeForge flake categories covered:
|
| 9 |
+
RESOURCE_LEAK, SHARED_STATE, ORDERING, TIMING, NETWORK, NONDETERMINISM
|
| 10 |
+
- Difficulty split: 35 EASY / 40 MEDIUM / 25 HARD
|
| 11 |
+
- Priority given to repos with accepted/opened PR (provides real fix diff)
|
| 12 |
+
- Repos with active maintenance preferred for diversity & runnability
|
| 13 |
+
|
| 14 |
+
Run from FlakeForge root:
|
| 15 |
+
.venv\\Scripts\\python.exe scripts\\build_idoft_dataset.py
|
| 16 |
+
"""
|
| 17 |
+
|
| 18 |
+
from __future__ import annotations
|
| 19 |
+
|
| 20 |
+
import json
|
| 21 |
+
import os
|
| 22 |
+
import subprocess
|
| 23 |
+
import time
|
| 24 |
+
import urllib.error
|
| 25 |
+
import urllib.request
|
| 26 |
+
from dataclasses import dataclass
|
| 27 |
+
from pathlib import Path
|
| 28 |
+
from typing import Optional
|
| 29 |
+
|
| 30 |
+
@dataclass
|
| 31 |
+
class RepoSpec:
|
| 32 |
+
repo_url: str
|
| 33 |
+
sha: str
|
| 34 |
+
test_path: str
|
| 35 |
+
idoft_category: str
|
| 36 |
+
pr_url: Optional[str]
|
| 37 |
+
difficulty: str # easy | medium | hard
|
| 38 |
+
flake_category: str
|
| 39 |
+
root_cause_file: str
|
| 40 |
+
root_cause_function: str
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
# ─────────────────────────────────────────────────────────────────────────────
|
| 44 |
+
# CURATED 100 REPOS — 35 Easy / 40 Medium / 25 Hard
|
| 45 |
+
# ─────────────────────────────────────────────────────────────────────────────
|
| 46 |
+
REPOS: list[RepoSpec] = [
|
| 47 |
+
|
| 48 |
+
# ═══════════════════════════════════════════════════════════════════
|
| 49 |
+
# EASY (35)
|
| 50 |
+
# Single-file small projects, obvious root cause from traceback,
|
| 51 |
+
# usually NIO or shallow OD. Great for teaching early diagnostic steps.
|
| 52 |
+
# ═══════════════════════════════════════════════════════════════════
|
| 53 |
+
|
| 54 |
+
# --- NIO: Unclosed resources (socket/file/handle left open) ---
|
| 55 |
+
RepoSpec("https://github.com/airbrake/pybrake","9bf82941d8bf521055b258cea91596a11e4eb81f",
|
| 56 |
+
"pybrake/test_celery_integration.py::test_celery_integration",
|
| 57 |
+
"NIO","https://github.com/airbrake/pybrake/pull/163","easy",
|
| 58 |
+
"RESOURCE_LEAK","pybrake/test_celery_integration.py","test_celery_integration"),
|
| 59 |
+
|
| 60 |
+
RepoSpec("https://github.com/chaosmail/python-fs","2567922ced9387e327e65f3244caff3b7af35684",
|
| 61 |
+
"fs/tests/test_mkdir.py::test_mkdir",
|
| 62 |
+
"NIO","https://github.com/chaosmail/python-fs/pull/9","easy",
|
| 63 |
+
"RESOURCE_LEAK","fs/tests/test_mkdir.py","test_mkdir"),
|
| 64 |
+
|
| 65 |
+
RepoSpec("https://github.com/chaosmail/python-fs","2567922ced9387e327e65f3244caff3b7af35684",
|
| 66 |
+
"fs/tests/test_rename.py::test_rename_directory",
|
| 67 |
+
"NIO","https://github.com/chaosmail/python-fs/pull/9","easy",
|
| 68 |
+
"RESOURCE_LEAK","fs/tests/test_rename.py","test_rename_directory"),
|
| 69 |
+
|
| 70 |
+
RepoSpec("https://github.com/chaosmail/python-fs","2567922ced9387e327e65f3244caff3b7af35684",
|
| 71 |
+
"fs/tests/test_touch.py::test_touch_on_new_file",
|
| 72 |
+
"NIO","https://github.com/chaosmail/python-fs/pull/9","easy",
|
| 73 |
+
"RESOURCE_LEAK","fs/tests/test_touch.py","test_touch_on_new_file"),
|
| 74 |
+
|
| 75 |
+
RepoSpec("https://github.com/bpepple/metron-tagger","2be176293218fa101dd84c95c72b8ae646b2731a",
|
| 76 |
+
"tests/test_taggerlib_filesorter.py::test_sort_comic",
|
| 77 |
+
"NIO","https://github.com/bpepple/metron-tagger/pull/46","easy",
|
| 78 |
+
"RESOURCE_LEAK","tests/test_taggerlib_filesorter.py","test_sort_comic"),
|
| 79 |
+
|
| 80 |
+
RepoSpec("https://github.com/DanielSank/observed","d99fb99ff2a470a86efb2763685e8e2c021e799f",
|
| 81 |
+
"observed_test.py::TestBasics::test_discard",
|
| 82 |
+
"NIO","https://github.com/DanielSank/observed/pull/24","easy",
|
| 83 |
+
"RESOURCE_LEAK","observed_test.py","test_discard"),
|
| 84 |
+
|
| 85 |
+
RepoSpec("https://github.com/drtexx/volux","c41339aceeab4295967ea88b2edd05d0d456b2ce",
|
| 86 |
+
"tests/test_operator.py::Test_operator::test_remove_missing_module",
|
| 87 |
+
"NIO","https://github.com/DrTexx/Volux/pull/36","easy",
|
| 88 |
+
"RESOURCE_LEAK","tests/test_operator.py","test_remove_missing_module"),
|
| 89 |
+
|
| 90 |
+
RepoSpec("https://github.com/drtexx/volux","c41339aceeab4295967ea88b2edd05d0d456b2ce",
|
| 91 |
+
"tests/test_operator.py::Test_operator::test_add_module",
|
| 92 |
+
"NIO","https://github.com/DrTexx/Volux/pull/37","easy",
|
| 93 |
+
"RESOURCE_LEAK","tests/test_operator.py","test_add_module"),
|
| 94 |
+
|
| 95 |
+
RepoSpec("https://github.com/davidhalter/parso","6ae0efa415c9790000dba70f87e6ece20d6a4101",
|
| 96 |
+
"test/test_cache.py::test_permission_error",
|
| 97 |
+
"NIO","https://github.com/davidhalter/parso/pull/195","easy",
|
| 98 |
+
"RESOURCE_LEAK","test/test_cache.py","test_permission_error"),
|
| 99 |
+
|
| 100 |
+
RepoSpec("https://github.com/daknuett/ljson","1d3dc13001d9d2f61bcfbf9d5c5b16d44fb8076e",
|
| 101 |
+
"test/test_ljson_mem.py::test_unique_check",
|
| 102 |
+
"NIO","https://github.com/daknuett/ljson/pull/4","easy",
|
| 103 |
+
"RESOURCE_LEAK","test/test_ljson_mem.py","test_unique_check"),
|
| 104 |
+
|
| 105 |
+
# --- NIO: Shared / global state leaks between tests ---
|
| 106 |
+
RepoSpec("https://github.com/chinapandaman/PyPDFForm","fcc1b297005a39900c1f928f602cbc5f38a1d60e",
|
| 107 |
+
"tests/unit/test_font.py::test_register_font_and_is_registered",
|
| 108 |
+
"NIO","https://github.com/chinapandaman/PyPDFForm/pull/254","easy",
|
| 109 |
+
"SHARED_STATE","tests/unit/test_font.py","test_register_font_and_is_registered"),
|
| 110 |
+
|
| 111 |
+
RepoSpec("https://github.com/CitrineInformatics/citrine-python","cf2ec34cad3f1ff44478cad44e4918d7925cba25",
|
| 112 |
+
"tests/_util/test_functions.py::test_shadow_classes_in_module",
|
| 113 |
+
"NIO","https://github.com/CitrineInformatics/citrine-python/pull/690","easy",
|
| 114 |
+
"SHARED_STATE","tests/_util/test_functions.py","test_shadow_classes_in_module"),
|
| 115 |
+
|
| 116 |
+
RepoSpec("https://github.com/chie8842/expstock","28b7ece83fa601efd2b6c4b8b7bdd1a3b9ebc1df",
|
| 117 |
+
"expstock/test_expstock.py::test_append_param",
|
| 118 |
+
"NIO","https://github.com/chie8842/expstock/pull/18","easy",
|
| 119 |
+
"SHARED_STATE","expstock/test_expstock.py","test_append_param"),
|
| 120 |
+
|
| 121 |
+
RepoSpec("https://github.com/den4uk/jsonextra","b548d607e8f196e1e1c456666e4f15f579762ecf",
|
| 122 |
+
"tests/test_jsonextra.py::test_disable_rex",
|
| 123 |
+
"NIO","https://github.com/den4uk/jsonextra/pull/10","easy",
|
| 124 |
+
"SHARED_STATE","tests/test_jsonextra.py","test_disable_rex"),
|
| 125 |
+
|
| 126 |
+
RepoSpec("https://github.com/demisto/demisto-sdk","925303e5c54fc3dda985ef19b6d56a956d189f69",
|
| 127 |
+
"demisto_sdk/commands/secrets/tests/secrets_test.py::TestSecrets::test_two_files_with_same_name",
|
| 128 |
+
"NIO","https://github.com/demisto/demisto-sdk/pull/1574","easy",
|
| 129 |
+
"SHARED_STATE","demisto_sdk/commands/secrets/tests/secrets_test.py","test_two_files_with_same_name"),
|
| 130 |
+
|
| 131 |
+
# --- OD-Brit and OD: Simple order-dependency, global state ---
|
| 132 |
+
RepoSpec("https://github.com/agile4you/bottle-neck","ebc670a4b178255473d68e9b4122ba04e38f4810",
|
| 133 |
+
"test/test_routing.py::test_router_mount_pass",
|
| 134 |
+
"OD","https://github.com/agile4you/bottle-neck/pull/4","easy",
|
| 135 |
+
"ORDER_DEPENDENCY","test/test_routing.py","test_router_mount_pass"),
|
| 136 |
+
|
| 137 |
+
RepoSpec("https://github.com/codespell-project/codespell","4f2325ec18ccef64ed8062f63dc63d360fa33f47",
|
| 138 |
+
"test_dictionary.py::test_dictionary_looping",
|
| 139 |
+
"OD","https://github.com/codespell-project/codespell/pull/2105","easy",
|
| 140 |
+
"ORDER_DEPENDENCY","test_dictionary.py","test_dictionary_looping"),
|
| 141 |
+
|
| 142 |
+
RepoSpec("https://github.com/agronholm/typeguard","2fe7a63e7e7fe6b7910fd423ba82eea4bffb1f17",
|
| 143 |
+
"test_typeguard.py::test_check_call_args",
|
| 144 |
+
"OD","https://github.com/agronholm/typeguard/pull/220","easy",
|
| 145 |
+
"ORDER_DEPENDENCY","test_typeguard.py","test_check_call_args"),
|
| 146 |
+
|
| 147 |
+
RepoSpec("https://github.com/crumpstrr33/Utter-More","418cf5f5ef337700bf0897e746eff8bb275e6da6",
|
| 148 |
+
"test/test_utter_more.py::test_saving_utterances[csv_test-csv-None]",
|
| 149 |
+
"OD-Brit","https://github.com/crumpstrr33/Utter-More/pull/2","easy",
|
| 150 |
+
"ORDER_DEPENDENCY","test/test_utter_more.py","test_saving_utterances"),
|
| 151 |
+
|
| 152 |
+
RepoSpec("https://github.com/crumpstrr33/Utter-More","418cf5f5ef337700bf0897e746eff8bb275e6da6",
|
| 153 |
+
"test/test_utter_more.py::test_saving_utterances[txt_test-txt-None]",
|
| 154 |
+
"OD-Brit","https://github.com/crumpstrr33/Utter-More/pull/2","easy",
|
| 155 |
+
"ORDER_DEPENDENCY","test/test_utter_more.py","test_saving_utterances"),
|
| 156 |
+
|
| 157 |
+
RepoSpec("https://github.com/DevKeh/redisqueue","feac4dfc30837e0ab1a55a8479443ea74b2793f2",
|
| 158 |
+
"tests/test_redisqueue.py::test_mock_queue_get_put_same_task",
|
| 159 |
+
"OD-Brit","https://github.com/jkehler/redisqueue/pull/4","easy",
|
| 160 |
+
"ORDER_DEPENDENCY","tests/test_redisqueue.py","test_mock_queue_get_put_same_task"),
|
| 161 |
+
|
| 162 |
+
RepoSpec("https://github.com/DevKeh/redisqueue","feac4dfc30837e0ab1a55a8479443ea74b2793f2",
|
| 163 |
+
"tests/test_redisqueue.py::test_mock_queue_put_get",
|
| 164 |
+
"OD-Brit","https://github.com/jkehler/redisqueue/pull/4","easy",
|
| 165 |
+
"ORDER_DEPENDENCY","tests/test_redisqueue.py","test_mock_queue_put_get"),
|
| 166 |
+
|
| 167 |
+
RepoSpec("https://github.com/DevKeh/redisqueue","feac4dfc30837e0ab1a55a8479443ea74b2793f2",
|
| 168 |
+
"tests/test_redisqueue.py::test_mock_queue_connection",
|
| 169 |
+
"NIO","https://github.com/jkehler/redisqueue/pull/3","easy",
|
| 170 |
+
"SHARED_STATE","tests/test_redisqueue.py","test_mock_queue_connection"),
|
| 171 |
+
|
| 172 |
+
RepoSpec("https://github.com/didix21/mdutils","2ef859ecc1e4f1a3e24f4150c96f61180bc57ceb",
|
| 173 |
+
"tests/test_mdutils.py::TestMdUtils::test_create_md_file",
|
| 174 |
+
"OD-Vic","https://github.com/didix21/mdutils/pull/76","easy",
|
| 175 |
+
"ORDER_DEPENDENCY","tests/test_mdutils.py","test_create_md_file"),
|
| 176 |
+
|
| 177 |
+
RepoSpec("https://github.com/didix21/mdutils","2ef859ecc1e4f1a3e24f4150c96f61180bc57ceb",
|
| 178 |
+
"tests/test_mdutils.py::TestMdUtils::test_new_header",
|
| 179 |
+
"OD-Vic","https://github.com/didix21/mdutils/pull/76","easy",
|
| 180 |
+
"ORDER_DEPENDENCY","tests/test_mdutils.py","test_new_header"),
|
| 181 |
+
|
| 182 |
+
RepoSpec("https://github.com/datacommonsorg/api-python","16fabfc49a9c7d46a5b5847b0d9809242ec884e5",
|
| 183 |
+
"set_api_key_test.py::TestApiKey::test_query_no_api_key?",
|
| 184 |
+
"OD","https://github.com/datacommonsorg/api-python/pull/175","easy",
|
| 185 |
+
"SHARED_STATE","set_api_key_test.py","test_query_no_api_key"),
|
| 186 |
+
|
| 187 |
+
RepoSpec("https://github.com/datacommonsorg/api-python","16fabfc49a9c7d46a5b5847b0d9809242ec884e5",
|
| 188 |
+
"set_api_key_test.py::TestApiKey::test_send_request_no_api_key?",
|
| 189 |
+
"OD","https://github.com/datacommonsorg/api-python/pull/175","easy",
|
| 190 |
+
"SHARED_STATE","set_api_key_test.py","test_send_request_no_api_key"),
|
| 191 |
+
|
| 192 |
+
# --- ID / Environment Sensitive ---
|
| 193 |
+
RepoSpec("https://github.com/clapeyre/h5nav","1c866a5085f1605a51064f5642e91d3682f8f572",
|
| 194 |
+
"tests/test_cli.py::test_cat",
|
| 195 |
+
"ID","https://github.com/clapeyre/h5nav/pull/7","easy",
|
| 196 |
+
"ENVIRONMENT_SENSITIVE","tests/test_cli.py","test_cat"),
|
| 197 |
+
|
| 198 |
+
RepoSpec("https://github.com/CyberZHG/keras-layer-normalization","077dc72b6bae1a0551c3b879557478b587be340d",
|
| 199 |
+
"tests/test_layer_normalization.py",
|
| 200 |
+
"ID","https://github.com/CyberZHG/keras-layer-normalization/pull/4","easy",
|
| 201 |
+
"ENVIRONMENT_SENSITIVE","tests/test_layer_normalization.py","test_layer_normalization"),
|
| 202 |
+
|
| 203 |
+
RepoSpec("https://github.com/CyberZHG/keras-transformer","52a3fa27d5598eca23be9031fc67a011d42cbefb",
|
| 204 |
+
"tests/test_get_model.py",
|
| 205 |
+
"ID","https://github.com/CyberZHG/keras-transformer/pull/42","easy",
|
| 206 |
+
"ENVIRONMENT_SENSITIVE","tests/test_get_model.py","test_get_model"),
|
| 207 |
+
|
| 208 |
+
# --- NIO: Nondeterminism (random seeds) ---
|
| 209 |
+
RepoSpec("https://github.com/brightway-lca/stats_arrays","3d3480d064365571e64e736dcbd778badb1210db",
|
| 210 |
+
"extreme.py::GeneralizedExtremeValueUncertaintyTestCase::test_random_variables",
|
| 211 |
+
"OD","https://github.com/brightway-lca/stats_arrays/pull/10","easy",
|
| 212 |
+
"NONDETERMINISM","extreme.py","test_random_variables"),
|
| 213 |
+
|
| 214 |
+
RepoSpec("https://github.com/Burnysc2/python-sc2","78d2ebe5c87aa9abc1e8a505f095ccd5e9dec358",
|
| 215 |
+
"test/test_pickled_data.py::test_bot_ai",
|
| 216 |
+
"NIO","https://github.com/BurnySc2/python-sc2/pull/109","easy",
|
| 217 |
+
"NONDETERMINISM","test/test_pickled_data.py","test_bot_ai"),
|
| 218 |
+
|
| 219 |
+
RepoSpec("https://github.com/ConSou/devtracker","ea892d6d48aa5d4627b429469b59ae3f0ce7f10f",
|
| 220 |
+
"devtracker/test_devtracker.py::test_end_time_total",
|
| 221 |
+
"NIO",None,"easy",
|
| 222 |
+
"RESOURCE_LEAK","devtracker/test_devtracker.py","test_end_time_total"),
|
| 223 |
+
|
| 224 |
+
RepoSpec("https://github.com/ConSou/devtracker","ea892d6d48aa5d4627b429469b59ae3f0ce7f10f",
|
| 225 |
+
"devtracker/test_devtracker.py::test_remove",
|
| 226 |
+
"NIO",None,"easy",
|
| 227 |
+
"RESOURCE_LEAK","devtracker/test_devtracker.py","test_remove"),
|
| 228 |
+
|
| 229 |
+
RepoSpec("https://github.com/crumpstrr33/Utter-More","418cf5f5ef337700bf0897e746eff8bb275e6da6",
|
| 230 |
+
"test/test_utter_more.py::test_ibu_aut",
|
| 231 |
+
"NIO","https://github.com/crumpstrr33/Utter-More/pull/1","easy",
|
| 232 |
+
"RESOURCE_LEAK","test/test_utter_more.py","test_ibu_aut"),
|
| 233 |
+
|
| 234 |
+
RepoSpec("https://github.com/Cornices/cornice.ext.swagger","17a63e86751c7d8f1b2b75d49056161c80f4cef2",
|
| 235 |
+
"tests/test_app.py::AppSpecViewTest::test_validate_spec",
|
| 236 |
+
"OD-Vic",None,"easy",
|
| 237 |
+
"ORDER_DEPENDENCY","tests/test_app.py","test_validate_spec"),
|
| 238 |
+
|
| 239 |
+
# ═══════════════════════════════════════════════════════════════════
|
| 240 |
+
# MEDIUM (40)
|
| 241 |
+
# Multi-file, imports involved, moderate diagnostic effort needed.
|
| 242 |
+
# Mix of NOD (timing) and OD-Vic (state tracing across tests).
|
| 243 |
+
# ═══════════════════════════════════════════════════════════════════
|
| 244 |
+
|
| 245 |
+
# --- NOD: Timing / Async ---
|
| 246 |
+
RepoSpec("https://github.com/AGTGreg/runium","ba89015859976d3426d25a53af5fa4d8827c7483",
|
| 247 |
+
"tests/test_runium.py::TestStartIn::test_processing",
|
| 248 |
+
"NOD",None,"medium",
|
| 249 |
+
"TIMING","tests/test_runium.py","test_processing"),
|
| 250 |
+
|
| 251 |
+
RepoSpec("https://github.com/AGTGreg/runium","ba89015859976d3426d25a53af5fa4d8827c7483",
|
| 252 |
+
"tests/test_runium.py::TestTaskSkipping::test_processing",
|
| 253 |
+
"NOD",None,"medium",
|
| 254 |
+
"TIMING","tests/test_runium.py","test_processing_skipping"),
|
| 255 |
+
|
| 256 |
+
RepoSpec("https://github.com/casamagalhaes/panamah-sdk-python","746f3fb7ebcf01810917bf9afa8e7ff5a4efad21",
|
| 257 |
+
"tests/test_processor.py::TestStream::test_expiration_by_max_age",
|
| 258 |
+
"NOD",None,"medium",
|
| 259 |
+
"TIMING","tests/test_processor.py","test_expiration_by_max_age"),
|
| 260 |
+
|
| 261 |
+
RepoSpec("https://github.com/casamagalhaes/panamah-sdk-python","746f3fb7ebcf01810917bf9afa8e7ff5a4efad21",
|
| 262 |
+
"tests/test_processor.py::TestStream::test_expiration_by_max_length",
|
| 263 |
+
"NOD",None,"medium",
|
| 264 |
+
"TIMING","tests/test_processor.py","test_expiration_by_max_length"),
|
| 265 |
+
|
| 266 |
+
RepoSpec("https://github.com/casamagalhaes/panamah-sdk-python","746f3fb7ebcf01810917bf9afa8e7ff5a4efad21",
|
| 267 |
+
"tests/test_processor.py::TestStream::test_initialization_and_accumulation",
|
| 268 |
+
"OD-Vic",None,"medium",
|
| 269 |
+
"TIMING","tests/test_processor.py","test_initialization_and_accumulation"),
|
| 270 |
+
|
| 271 |
+
RepoSpec("https://github.com/dmarkey/aiopylimit","b4075a8ac30dbbef59a2243f618a35a2f54b590c",
|
| 272 |
+
"aiopylimit/tests/test_aiopylimit.py::TestPyLimit::test_exception",
|
| 273 |
+
"OD-Vic",None,"medium",
|
| 274 |
+
"TIMING","aiopylimit/tests/test_aiopylimit.py","test_exception"),
|
| 275 |
+
|
| 276 |
+
RepoSpec("https://github.com/cr0hn/aiotasks","ec485c84db55227c9283319a9c58401e294a06ff",
|
| 277 |
+
"tests/unittesting/tasks/memory/test_delay.py::test_memory_delay_add_task_non_coroutine_as_input",
|
| 278 |
+
"OD-Vic",None,"medium",
|
| 279 |
+
"TIMING","tests/unittesting/tasks/memory/test_delay.py","test_memory_delay_add_task_non_coroutine_as_input"),
|
| 280 |
+
|
| 281 |
+
RepoSpec("https://github.com/cr0hn/aiotasks","ec485c84db55227c9283319a9c58401e294a06ff",
|
| 282 |
+
"tests/unittesting/tasks/memory/test_subscribers.py::test_memory_subscribers_empty_topics",
|
| 283 |
+
"OD-Vic",None,"medium",
|
| 284 |
+
"TIMING","tests/unittesting/tasks/memory/test_subscribers.py","test_memory_subscribers_empty_topics"),
|
| 285 |
+
|
| 286 |
+
# --- NOD: Network dependency ---
|
| 287 |
+
RepoSpec("https://github.com/chakki-works/chazutsu","52b2f8acaac1b6c7eab97dded1a1757d3adcefa7",
|
| 288 |
+
"tests/test_movie_review.py::test_download",
|
| 289 |
+
"NOD","https://github.com/chakki-works/chazutsu/pull/13","medium",
|
| 290 |
+
"NETWORK","tests/test_movie_review.py","test_download"),
|
| 291 |
+
|
| 292 |
+
RepoSpec("https://github.com/bennymeg/Butter.MAS.PythonAPI","f86ebe75df3826f62a268645cdbe4400b43fab07",
|
| 293 |
+
"butter/mas/tests/clients/client_http_test.py::TestHttpClientApiMethods::testGetAvailableAnimations",
|
| 294 |
+
"NOD",None,"medium",
|
| 295 |
+
"NETWORK","butter/mas/tests/clients/client_http_test.py","testGetAvailableAnimations"),
|
| 296 |
+
|
| 297 |
+
RepoSpec("https://github.com/bennymeg/Butter.MAS.PythonAPI","f86ebe75df3826f62a268645cdbe4400b43fab07",
|
| 298 |
+
"butter/mas/tests/clients/client_tcp_test.py::TestTcpClientApiMethods::testGetAvailableHandlers",
|
| 299 |
+
"NOD",None,"medium",
|
| 300 |
+
"NETWORK","butter/mas/tests/clients/client_tcp_test.py","testGetAvailableHandlers"),
|
| 301 |
+
|
| 302 |
+
RepoSpec("https://github.com/DevKeh/redisqueue","feac4dfc30837e0ab1a55a8479443ea74b2793f2",
|
| 303 |
+
"tests/test_redisqueue.py::test_mock_queue_unique",
|
| 304 |
+
"OD-Brit","https://github.com/jkehler/redisqueue/pull/4","medium",
|
| 305 |
+
"NETWORK","tests/test_redisqueue.py","test_mock_queue_unique"),
|
| 306 |
+
|
| 307 |
+
# --- OD-Vic: State tracing across tests (multi-hop) ---
|
| 308 |
+
RepoSpec("https://github.com/actris-cloudnet/cloudnetpy","4caaec217c8e100fa2a11f76654917e0bd9560a7",
|
| 309 |
+
"tests/unit/test_meta_for_old_files.py::test_fix_old_data_2",
|
| 310 |
+
"NIO","https://github.com/actris-cloudnet/cloudnetpy/pull/34","medium",
|
| 311 |
+
"SHARED_STATE","tests/unit/test_meta_for_old_files.py","test_fix_old_data_2"),
|
| 312 |
+
|
| 313 |
+
RepoSpec("https://github.com/actris-cloudnet/cloudnetpy","c6c728ae760a43e4efbd04d469d6fd1adbb1d1f7",
|
| 314 |
+
"tests/unit/test_util.py::test_l2_norm",
|
| 315 |
+
"NIO","https://github.com/actris-cloudnet/cloudnetpy/pull/35","medium",
|
| 316 |
+
"NONDETERMINISM","tests/unit/test_util.py","test_l2_norm"),
|
| 317 |
+
|
| 318 |
+
RepoSpec("https://github.com/abeelen/nikamap","d39c488c89bf5d90fffe3c05580fc13551522ee5",
|
| 319 |
+
"nikamap/tests/test_nikamap.py::test_nikamap_write",
|
| 320 |
+
"NIO","https://github.com/abeelen/nikamap/pull/2","medium",
|
| 321 |
+
"SHARED_STATE","nikamap/tests/test_nikamap.py","test_nikamap_write"),
|
| 322 |
+
|
| 323 |
+
RepoSpec("https://github.com/chinapnr/fishbase","73ae6701eee5d1bdd31a3e7b4d86fa11de9b6522",
|
| 324 |
+
"test_common.py::TestFishCommon::test_yaml_conf_as_dict_01",
|
| 325 |
+
"NOD","https://github.com/chinapnr/fishbase/pull/302","medium",
|
| 326 |
+
"SHARED_STATE","test_common.py","test_yaml_conf_as_dict_01"),
|
| 327 |
+
|
| 328 |
+
RepoSpec("https://github.com/daknuett/ljson","1d3dc13001d9d2f61bcfbf9d5c5b16d44fb8076e",
|
| 329 |
+
"test/test_ljson_disk.py::test_contains",
|
| 330 |
+
"OD-Vic",None,"medium",
|
| 331 |
+
"ORDER_DEPENDENCY","test/test_ljson_disk.py","test_contains"),
|
| 332 |
+
|
| 333 |
+
RepoSpec("https://github.com/daknuett/ljson","1d3dc13001d9d2f61bcfbf9d5c5b16d44fb8076e",
|
| 334 |
+
"test/test_ljson_disk.py::test_edit",
|
| 335 |
+
"OD-Vic",None,"medium",
|
| 336 |
+
"ORDER_DEPENDENCY","test/test_ljson_disk.py","test_edit"),
|
| 337 |
+
|
| 338 |
+
RepoSpec("https://github.com/daknuett/ljson","1d3dc13001d9d2f61bcfbf9d5c5b16d44fb8076e",
|
| 339 |
+
"test/test_ljson_disk.py::test_read",
|
| 340 |
+
"OD-Vic",None,"medium",
|
| 341 |
+
"ORDER_DEPENDENCY","test/test_ljson_disk.py","test_read"),
|
| 342 |
+
|
| 343 |
+
RepoSpec("https://github.com/DanielSank/observed","d99fb99ff2a470a86efb2763685e8e2c021e799f",
|
| 344 |
+
"observed_test.py::TestBasics::test_callbacks",
|
| 345 |
+
"OD-Vic",None,"medium",
|
| 346 |
+
"ORDER_DEPENDENCY","observed_test.py","test_callbacks"),
|
| 347 |
+
|
| 348 |
+
RepoSpec("https://github.com/avature/confight","be2d162c99cc3c709289913de137f8d8bfbd35d5",
|
| 349 |
+
"test_confight.py::TestLoad::test_it_should_load_and_merge_lists_of_paths",
|
| 350 |
+
"OD-Vic",None,"medium",
|
| 351 |
+
"ORDER_DEPENDENCY","test_confight.py","test_it_should_load_and_merge_lists_of_paths"),
|
| 352 |
+
|
| 353 |
+
RepoSpec("https://github.com/coinbase/coinbase-commerce-python","d306fc562309edb909c8ace501c63327a7635975",
|
| 354 |
+
"tests/api_resources/base/test_create_api_resource.py::TestCreateAPIResource::test_create",
|
| 355 |
+
"OD-Vic",None,"medium",
|
| 356 |
+
"ORDER_DEPENDENCY","tests/api_resources/base/test_create_api_resource.py","test_create"),
|
| 357 |
+
|
| 358 |
+
RepoSpec("https://github.com/coinbase/coinbase-commerce-python","d306fc562309edb909c8ace501c63327a7635975",
|
| 359 |
+
"tests/api_resources/test_charge.py::TestCharge::test_list_iter_mapping",
|
| 360 |
+
"OD-Vic",None,"medium",
|
| 361 |
+
"ORDER_DEPENDENCY","tests/api_resources/test_charge.py","test_list_iter_mapping"),
|
| 362 |
+
|
| 363 |
+
RepoSpec("https://github.com/ConSou/devtracker","ea892d6d48aa5d4627b429469b59ae3f0ce7f10f",
|
| 364 |
+
"devtracker/test_devtracker.py::test_full_report",
|
| 365 |
+
"NOD",None,"medium",
|
| 366 |
+
"TIMING","devtracker/test_devtracker.py","test_full_report"),
|
| 367 |
+
|
| 368 |
+
RepoSpec("https://github.com/ConSou/devtracker","ea892d6d48aa5d4627b429469b59ae3f0ce7f10f",
|
| 369 |
+
"devtracker/test_devtracker.py::test_make_project_csv_initalize",
|
| 370 |
+
"NOD",None,"medium",
|
| 371 |
+
"TIMING","devtracker/test_devtracker.py","test_make_project_csv_initalize"),
|
| 372 |
+
|
| 373 |
+
RepoSpec("https://github.com/djrobstep/logx","c53fabbc160fb8d70fa878684ea36f0c22fd5caa",
|
| 374 |
+
"tests/test_logx.py::test_formatted_output",
|
| 375 |
+
"OD-Vic",None,"medium",
|
| 376 |
+
"ORDER_DEPENDENCY","tests/test_logx.py","test_formatted_output"),
|
| 377 |
+
|
| 378 |
+
RepoSpec("https://github.com/djrobstep/logx","c53fabbc160fb8d70fa878684ea36f0c22fd5caa",
|
| 379 |
+
"tests/test_logx.py::test_plain_output",
|
| 380 |
+
"OD-Vic",None,"medium",
|
| 381 |
+
"ORDER_DEPENDENCY","tests/test_logx.py","test_plain_output"),
|
| 382 |
+
|
| 383 |
+
RepoSpec("https://github.com/djrobstep/logx","c53fabbc160fb8d70fa878684ea36f0c22fd5caa",
|
| 384 |
+
"tests/test_logx.py::test_set_format",
|
| 385 |
+
"OD-Vic",None,"medium",
|
| 386 |
+
"ORDER_DEPENDENCY","tests/test_logx.py","test_set_format"),
|
| 387 |
+
|
| 388 |
+
RepoSpec("https://github.com/d4nuu8/krllint","2f9376cdae14c201364d9c31b4c19a8ff2f708d2",
|
| 389 |
+
"tests/test_extraneous_whitespace.py::ExtraneousWhiteSpaceTestCase::test_rule_without_fix",
|
| 390 |
+
"OD-Vic",None,"medium",
|
| 391 |
+
"ORDER_DEPENDENCY","tests/test_extraneous_whitespace.py","test_rule_without_fix"),
|
| 392 |
+
|
| 393 |
+
RepoSpec("https://github.com/d4nuu8/krllint","2f9376cdae14c201364d9c31b4c19a8ff2f708d2",
|
| 394 |
+
"tests/test_indentation_checker.py::IndentationCheckerTestCase::test_rule_with_fix",
|
| 395 |
+
"OD-Vic",None,"medium",
|
| 396 |
+
"ORDER_DEPENDENCY","tests/test_indentation_checker.py","test_rule_with_fix"),
|
| 397 |
+
|
| 398 |
+
RepoSpec("https://github.com/d4nuu8/krllint","2f9376cdae14c201364d9c31b4c19a8ff2f708d2",
|
| 399 |
+
"tests/test_trailing_white_space_rule.py::TrailingWhiteSpaceTestCase::test_rule_with_fix",
|
| 400 |
+
"OD-Vic",None,"medium",
|
| 401 |
+
"ORDER_DEPENDENCY","tests/test_trailing_white_space_rule.py","test_rule_with_fix"),
|
| 402 |
+
|
| 403 |
+
RepoSpec("https://github.com/dowjones/factiva-news-python","763b178fe7fd3716e5542cf098600ef56c6fbd62",
|
| 404 |
+
"__init__.py",
|
| 405 |
+
"OD","https://github.com/dowjones/factiva-news-python/pull/8","medium",
|
| 406 |
+
"ORDER_DEPENDENCY","__init__.py","test_module_init"),
|
| 407 |
+
|
| 408 |
+
RepoSpec("https://github.com/dreid/yunomi","1aa3f166843613331b38e231264ffc3ac40e8094",
|
| 409 |
+
"yunomi/tests/test_metrics_registry.py::MetricsRegistryTests::test_count_calls_decorator",
|
| 410 |
+
"OD-Vic",None,"medium",
|
| 411 |
+
"ORDER_DEPENDENCY","yunomi/tests/test_metrics_registry.py","test_count_calls_decorator"),
|
| 412 |
+
|
| 413 |
+
RepoSpec("https://github.com/dreid/yunomi","1aa3f166843613331b38e231264ffc3ac40e8094",
|
| 414 |
+
"yunomi/tests/test_metrics_registry.py::MetricsRegistryTests::test_hist_calls_decorator",
|
| 415 |
+
"OD-Vic",None,"medium",
|
| 416 |
+
"ORDER_DEPENDENCY","yunomi/tests/test_metrics_registry.py","test_hist_calls_decorator"),
|
| 417 |
+
|
| 418 |
+
RepoSpec("https://github.com/dreid/yunomi","1aa3f166843613331b38e231264ffc3ac40e8094",
|
| 419 |
+
"yunomi/tests/test_metrics_registry.py::MetricsRegistryTests::test_time_calls_decorator",
|
| 420 |
+
"OD-Vic",None,"medium",
|
| 421 |
+
"ORDER_DEPENDENCY","yunomi/tests/test_metrics_registry.py","test_time_calls_decorator"),
|
| 422 |
+
|
| 423 |
+
RepoSpec("https://github.com/dmytrostriletskyi/accessify","6b7cf8657ffe18cd6a43c6cfb73b071084f0331e",
|
| 424 |
+
"tests/interfaces/test_implements.py::test_implements_not_cls_convention",
|
| 425 |
+
"OD-Vic",None,"medium",
|
| 426 |
+
"ORDER_DEPENDENCY","tests/interfaces/test_implements.py","test_implements_not_cls_convention"),
|
| 427 |
+
|
| 428 |
+
RepoSpec("https://github.com/dmytrostriletskyi/accessify","6b7cf8657ffe18cd6a43c6cfb73b071084f0331e",
|
| 429 |
+
"tests/interfaces/test_implements.py::test_implements_not_self_convention",
|
| 430 |
+
"OD-Vic",None,"medium",
|
| 431 |
+
"ORDER_DEPENDENCY","tests/interfaces/test_implements.py","test_implements_not_self_convention"),
|
| 432 |
+
|
| 433 |
+
RepoSpec("https://github.com/dmytrostriletskyi/accessify","6b7cf8657ffe18cd6a43c6cfb73b071084f0331e",
|
| 434 |
+
"tests/interfaces/test_implements.py::test_implements_no_implementation_instance_method",
|
| 435 |
+
"OD-Vic",None,"medium",
|
| 436 |
+
"ORDER_DEPENDENCY","tests/interfaces/test_implements.py","test_implements_no_implementation_instance_method"),
|
| 437 |
+
|
| 438 |
+
RepoSpec("https://github.com/dgilland/pydash","24ad0e43b51b367d00447c45baa68c9c03ad1a52",
|
| 439 |
+
"tests/test_utilities.py::test_unique_id",
|
| 440 |
+
"OD",None,"medium",
|
| 441 |
+
"NONDETERMINISM","tests/test_utilities.py","test_unique_id"),
|
| 442 |
+
|
| 443 |
+
RepoSpec("https://github.com/cbsinteractive/elemental","ca570c9f5fc43e20a4db23fc0137380f87be63f0",
|
| 444 |
+
"elemental/client_test.py::test_send_request_should_call_request_as_expected",
|
| 445 |
+
"OD-Vic",None,"medium",
|
| 446 |
+
"ORDER_DEPENDENCY","elemental/client_test.py","test_send_request_should_call_request_as_expected"),
|
| 447 |
+
|
| 448 |
+
# ═══════════════════════════════════════════════════════════════════
|
| 449 |
+
# HARD (25)
|
| 450 |
+
# Large codebases, deep import chains, multi-component interaction,
|
| 451 |
+
# requires real diagnostic traversal (chaos probe, log analysis, etc.)
|
| 452 |
+
# ═══════════════════════════════════════════════════════════════════
|
| 453 |
+
|
| 454 |
+
# --- Hard NOD: Django and large framework flakes ---
|
| 455 |
+
RepoSpec("https://github.com/django-beam/django-beam","43b2111f5c65937aa1e2cc9704b59796e982a805",
|
| 456 |
+
"tests/test_views.py::test_delete",
|
| 457 |
+
"NOD",None,"hard",
|
| 458 |
+
"SHARED_STATE","tests/test_views.py","test_delete"),
|
| 459 |
+
|
| 460 |
+
RepoSpec("https://github.com/django-beam/django-beam","43b2111f5c65937aa1e2cc9704b59796e982a805",
|
| 461 |
+
"tests/test_views.py::test_list",
|
| 462 |
+
"NOD",None,"hard",
|
| 463 |
+
"SHARED_STATE","tests/test_views.py","test_list"),
|
| 464 |
+
|
| 465 |
+
RepoSpec("https://github.com/django-beam/django-beam","43b2111f5c65937aa1e2cc9704b59796e982a805",
|
| 466 |
+
"tests/test_contrib_reversion.py::test_revision_is_visible_in_list",
|
| 467 |
+
"NOD",None,"hard",
|
| 468 |
+
"SHARED_STATE","tests/test_contrib_reversion.py","test_revision_is_visible_in_list"),
|
| 469 |
+
|
| 470 |
+
RepoSpec("https://github.com/django-beam/django-beam","43b2111f5c65937aa1e2cc9704b59796e982a805",
|
| 471 |
+
"tests/test_views.py::test_list_search",
|
| 472 |
+
"NOD",None,"hard",
|
| 473 |
+
"SHARED_STATE","tests/test_views.py","test_list_search"),
|
| 474 |
+
|
| 475 |
+
RepoSpec("https://github.com/django-beam/django-beam","43b2111f5c65937aa1e2cc9704b59796e982a805",
|
| 476 |
+
"tests/test_views.py::test_update",
|
| 477 |
+
"NOD",None,"hard",
|
| 478 |
+
"SHARED_STATE","tests/test_views.py","test_update"),
|
| 479 |
+
|
| 480 |
+
# --- Hard: Apache Beam (very large codebase, distributed system) ---
|
| 481 |
+
RepoSpec("https://github.com/apache/beam","faae168fa34e97475df70b707f4df91c4946c6ca",
|
| 482 |
+
"sdks/python/apache_beam/runners/portability/portable_runner_test.py::PortableRunnerTest::test_assert_that",
|
| 483 |
+
"OD","https://github.com/apache/beam/pull/36485","hard",
|
| 484 |
+
"RACE_CONDITION","sdks/python/apache_beam/runners/portability/portable_runner_test.py","test_assert_that"),
|
| 485 |
+
|
| 486 |
+
# --- Hard: Network servers with async/socket lifecycle ---
|
| 487 |
+
RepoSpec("https://github.com/abhinavsingh/proxy.py","9b4263777bea7b3e00a1a3546511f2117f210a2b",
|
| 488 |
+
"tests/common/test_utils.py::TestSocketConnectionUtils::test_new_socket_connection_ipv4",
|
| 489 |
+
"OD-Vic",None,"hard",
|
| 490 |
+
"RACE_CONDITION","tests/common/test_utils.py","test_new_socket_connection_ipv4"),
|
| 491 |
+
|
| 492 |
+
RepoSpec("https://github.com/bennymeg/Butter.MAS.PythonAPI","f86ebe75df3826f62a268645cdbe4400b43fab07",
|
| 493 |
+
"butter/mas/tests/clients/client_udp_test.py::TestUdpClientApiMethods::testMoveMotorToPosition",
|
| 494 |
+
"NOD",None,"hard",
|
| 495 |
+
"NETWORK","butter/mas/tests/clients/client_udp_test.py","testMoveMotorToPosition"),
|
| 496 |
+
|
| 497 |
+
RepoSpec("https://github.com/bennymeg/Butter.MAS.PythonAPI","f86ebe75df3826f62a268645cdbe4400b43fab07",
|
| 498 |
+
"butter/mas/tests/clients/client_udp_test.py::TestUdpClientApiMethods::testStopAnimation",
|
| 499 |
+
"NOD",None,"hard",
|
| 500 |
+
"NETWORK","butter/mas/tests/clients/client_udp_test.py","testStopAnimation"),
|
| 501 |
+
|
| 502 |
+
RepoSpec("https://github.com/daliclass/rxpy-backpressure","b1a7bddfb370884135d6f2e7d9a3dd332a90d7cc",
|
| 503 |
+
"tests/test_sized_buffer_backpressure_strategy.py::TestDropBackPressureStrategy::test_on_next_drop_new_message_when_buffer_full",
|
| 504 |
+
"OD-Vic",None,"hard",
|
| 505 |
+
"RACE_CONDITION","tests/test_sized_buffer_backpressure_strategy.py","test_on_next_drop_new_message_when_buffer_full"),
|
| 506 |
+
|
| 507 |
+
# --- Hard: aiotasks (async task framework) ---
|
| 508 |
+
RepoSpec("https://github.com/cr0hn/aiotasks","ec485c84db55227c9283319a9c58401e294a06ff",
|
| 509 |
+
"tests/unittesting/tasks/test_base_async.py::test_build_manager_invalid_prefix",
|
| 510 |
+
"OD-Vic",None,"hard",
|
| 511 |
+
"TIMING","tests/unittesting/tasks/test_base_async.py","test_build_manager_invalid_prefix"),
|
| 512 |
+
|
| 513 |
+
# --- Hard: pybrake v2 (server + celery, resource lifecycle) ---
|
| 514 |
+
RepoSpec("https://github.com/airbrake/pybrake","1f991a6a9812d57cf84f24b348a0044b419733bf",
|
| 515 |
+
"pybrake/test_celery_integration.py::test_celery_integration",
|
| 516 |
+
"NIO","https://github.com/airbrake/pybrake/pull/165","hard",
|
| 517 |
+
"RESOURCE_LEAK","pybrake/test_celery_integration.py","test_celery_integration"),
|
| 518 |
+
|
| 519 |
+
# --- Hard: Cognexa/plcx (socket races) ---
|
| 520 |
+
RepoSpec("https://github.com/Cognexa/plcx","2756c0ba78c4c9e572d95ba002708957bc55d4fa",
|
| 521 |
+
"plcx/tests/comm/test_client.py::test_clientx_error",
|
| 522 |
+
"OD-Vic",None,"hard",
|
| 523 |
+
"RACE_CONDITION","plcx/tests/comm/test_client.py","test_clientx_error"),
|
| 524 |
+
|
| 525 |
+
RepoSpec("https://github.com/Cognexa/plcx","2756c0ba78c4c9e572d95ba002708957bc55d4fa",
|
| 526 |
+
"plcx/tests/comm/test_client.py::test_clientx_context[321]",
|
| 527 |
+
"OD-Vic",None,"hard",
|
| 528 |
+
"RACE_CONDITION","plcx/tests/comm/test_client.py","test_clientx_context"),
|
| 529 |
+
|
| 530 |
+
# --- Hard: Nondeterminism in statistics (floating point / random) ---
|
| 531 |
+
RepoSpec("https://github.com/brightway-lca/stats_arrays","3d3480d064365571e64e736dcbd778badb1210db",
|
| 532 |
+
"gama.py::GeneralizedExtremeValueUncertaintyTestCase::pretty_close",
|
| 533 |
+
"OD","https://github.com/brightway-lca/stats_arrays/pull/10","hard",
|
| 534 |
+
"NONDETERMINISM","gama.py","pretty_close"),
|
| 535 |
+
|
| 536 |
+
RepoSpec("https://github.com/brightway-lca/stats_arrays","3d3480d064365571e64e736dcbd778badb1210db",
|
| 537 |
+
"student.py::GeneralizedExtremeValueUncertaintyTestCase::test_scale_matters/pretty_close",
|
| 538 |
+
"OD","https://github.com/brightway-lca/stats_arrays/pull/10","hard",
|
| 539 |
+
"NONDETERMINISM","student.py","test_scale_matters"),
|
| 540 |
+
|
| 541 |
+
# --- Hard: Deep timing flakes in stream processor ---
|
| 542 |
+
RepoSpec("https://github.com/casamagalhaes/panamah-sdk-python","746f3fb7ebcf01810917bf9afa8e7ff5a4efad21",
|
| 543 |
+
"tests/test_stream.py::TestStream::test_events",
|
| 544 |
+
"NOD",None,"hard",
|
| 545 |
+
"TIMING","tests/test_stream.py","test_events"),
|
| 546 |
+
|
| 547 |
+
RepoSpec("https://github.com/casamagalhaes/panamah-sdk-python","746f3fb7ebcf01810917bf9afa8e7ff5a4efad21",
|
| 548 |
+
"tests/test_processor.py::TestStream::test_recover_failures",
|
| 549 |
+
"NOD",None,"hard",
|
| 550 |
+
"TIMING","tests/test_processor.py","test_recover_failures"),
|
| 551 |
+
|
| 552 |
+
RepoSpec("https://github.com/casamagalhaes/panamah-sdk-python","746f3fb7ebcf01810917bf9afa8e7ff5a4efad21",
|
| 553 |
+
"tests/test_processor.py::TestStream::test_requesting_pending_resources",
|
| 554 |
+
"NOD",None,"hard",
|
| 555 |
+
"TIMING","tests/test_processor.py","test_requesting_pending_resources"),
|
| 556 |
+
|
| 557 |
+
# --- Hard: Deep OD — mutating shared global singletons ---
|
| 558 |
+
RepoSpec("https://github.com/dowjones/tokendito","f5d8e5171ddcca0c62925263ddb80535f51aded3",
|
| 559 |
+
"tests/functional_test.py::test_package_exists",
|
| 560 |
+
"NOD",None,"hard",
|
| 561 |
+
"ENVIRONMENT_SENSITIVE","tests/functional_test.py","test_package_exists"),
|
| 562 |
+
|
| 563 |
+
RepoSpec("https://github.com/dowjones/tokendito","f5d8e5171ddcca0c62925263ddb80535f51aded3",
|
| 564 |
+
"tests/unit_test.py::test_set_okta_password",
|
| 565 |
+
"OD-Vic",None,"hard",
|
| 566 |
+
"ORDER_DEPENDENCY","tests/unit_test.py","test_set_okta_password"),
|
| 567 |
+
|
| 568 |
+
RepoSpec("https://github.com/dowjones/tokendito","f5d8e5171ddcca0c62925263ddb80535f51aded3",
|
| 569 |
+
"tests/unit_test.py::test_set_okta_username",
|
| 570 |
+
"OD-Vic",None,"hard",
|
| 571 |
+
"ORDER_DEPENDENCY","tests/unit_test.py","test_set_okta_username"),
|
| 572 |
+
|
| 573 |
+
RepoSpec("https://github.com/dmuhs/mythx-cli","f6b3d20d51ca1cfaed3cd45226bd1074388d42b9",
|
| 574 |
+
"tests/test_report.py::test_report_json",
|
| 575 |
+
"OD-Vic",None,"hard",
|
| 576 |
+
"ORDER_DEPENDENCY","tests/test_report.py","test_report_json"),
|
| 577 |
+
|
| 578 |
+
RepoSpec("https://github.com/dmuhs/mythx-cli","f6b3d20d51ca1cfaed3cd45226bd1074388d42b9",
|
| 579 |
+
"tests/test_report.py::test_report_tabular",
|
| 580 |
+
"OD-Vic",None,"hard",
|
| 581 |
+
"ORDER_DEPENDENCY","tests/test_report.py","test_report_tabular"),
|
| 582 |
+
|
| 583 |
+
RepoSpec("https://github.com/crazyscientist/osc-tiny","88c33c2c7f73ea26067e1bb4190bab5dc298dd85",
|
| 584 |
+
"osctiny/tests/test_issues.py::TestIssue::test_get",
|
| 585 |
+
"OD-Vic",None,"hard",
|
| 586 |
+
"ORDER_DEPENDENCY","osctiny/tests/test_issues.py","test_get"),
|
| 587 |
+
]
|
| 588 |
+
|
| 589 |
+
# ─────────────────────────────────────────────────────────────────────────────
|
| 590 |
+
# Category mappings
|
| 591 |
+
# ─────────────────────────────────────────────────────────────────────────────
|
| 592 |
+
|
| 593 |
+
DIFF_GUIDANCE = {
|
| 594 |
+
"RESOURCE_LEAK": ["RESET_STATE", "isolate_state"],
|
| 595 |
+
"SHARED_STATE": ["RESET_STATE", "isolate_state"],
|
| 596 |
+
"ORDER_DEPENDENCY": ["RESET_STATE", "reorder_execution", "isolate_state"],
|
| 597 |
+
"TIMING": ["ADD_TIMING_GUARD", "add_sleep", "ADD_SYNCHRONIZATION"],
|
| 598 |
+
"NETWORK": ["MOCK_DEPENDENCY", "mock_dependency", "ISOLATE_BOUNDARY"],
|
| 599 |
+
"NONDETERMINISM": ["SEED_RANDOMNESS", "MOCK_DEPENDENCY"],
|
| 600 |
+
"RACE_CONDITION": ["ADD_SYNCHRONIZATION", "add_lock", "REFACTOR_CONCURRENCY"],
|
| 601 |
+
"ENVIRONMENT_SENSITIVE": ["MOCK_DEPENDENCY", "CHAOS_PROBE", "isolate_state"],
|
| 602 |
+
}
|
| 603 |
+
|
| 604 |
+
BASE_DIR = Path(__file__).parent.parent / "seed_repos" / "idoft"
|
| 605 |
+
|
| 606 |
+
|
| 607 |
+
# ─────────────────────────────────────────────────────────────────────────────
|
| 608 |
+
# Helpers
|
| 609 |
+
# ─────────────────────────────────────────────────────────────────────────────
|
| 610 |
+
|
| 611 |
+
def _slug(repo_url: str, test_path: str) -> str:
|
| 612 |
+
repo_name = repo_url.rstrip("/").split("/")[-1]
|
| 613 |
+
func = test_path.split("::")[-1][:30].replace("[","").replace("]","").replace("/","_").replace("?","")
|
| 614 |
+
return f"{repo_name}__{func}"
|
| 615 |
+
|
| 616 |
+
|
| 617 |
+
def _clone_and_checkout(repo_url: str, sha: str, dest: Path) -> bool:
|
| 618 |
+
if not (dest / ".git").exists():
|
| 619 |
+
print(f" Cloning {repo_url} ...")
|
| 620 |
+
r = subprocess.run(["git", "clone", "--quiet", repo_url, str(dest)],
|
| 621 |
+
capture_output=True, text=True)
|
| 622 |
+
if r.returncode != 0:
|
| 623 |
+
print(f" ERROR: {r.stderr[:250]}")
|
| 624 |
+
return False
|
| 625 |
+
subprocess.run(["git", "checkout", sha, "--quiet"], cwd=dest, capture_output=True)
|
| 626 |
+
return True
|
| 627 |
+
|
| 628 |
+
|
| 629 |
+
def _fetch_diff(pr_url: Optional[str], dest: Path) -> bool:
|
| 630 |
+
if not pr_url:
|
| 631 |
+
return False
|
| 632 |
+
diff_url = pr_url.rstrip("/") + ".diff"
|
| 633 |
+
try:
|
| 634 |
+
req = urllib.request.Request(diff_url, headers={"User-Agent": "Mozilla/5.0"})
|
| 635 |
+
with urllib.request.urlopen(req, timeout=15) as resp:
|
| 636 |
+
data = resp.read()
|
| 637 |
+
(dest / "solution").mkdir(parents=True, exist_ok=True)
|
| 638 |
+
(dest / "solution" / "fix.diff").write_bytes(data)
|
| 639 |
+
print(f" Diff saved ({len(data)} bytes)")
|
| 640 |
+
return True
|
| 641 |
+
except Exception as exc:
|
| 642 |
+
print(f" No diff available: {exc}")
|
| 643 |
+
return False
|
| 644 |
+
|
| 645 |
+
|
| 646 |
+
def _write_manifest(spec: RepoSpec, dest: Path) -> None:
|
| 647 |
+
manifest = {
|
| 648 |
+
"repo_name": spec.repo_url.rstrip("/").split("/")[-1],
|
| 649 |
+
"flake_category": spec.flake_category,
|
| 650 |
+
"root_cause_file": spec.root_cause_file,
|
| 651 |
+
"root_cause_function": spec.root_cause_function
|
| 652 |
+
}
|
| 653 |
+
(dest / "flake_manifest.json").write_text(json.dumps(manifest, indent=2))
|
| 654 |
+
print(f" Manifest written → {dest.name}/flake_manifest.json")
|
| 655 |
+
|
| 656 |
+
|
| 657 |
+
# ─────────────────────────────────────────────────────────────────────────────
|
| 658 |
+
# Main
|
| 659 |
+
# ─────────────────────────────────────────────────────────────────────────────
|
| 660 |
+
|
| 661 |
+
def main() -> None:
|
| 662 |
+
BASE_DIR.mkdir(parents=True, exist_ok=True)
|
| 663 |
+
curriculum: dict[str, list[str]] = {"easy": [], "medium": [], "hard": []}
|
| 664 |
+
seen: set[str] = set()
|
| 665 |
+
success = 0
|
| 666 |
+
|
| 667 |
+
for i, spec in enumerate(REPOS, 1):
|
| 668 |
+
slug = _slug(spec.repo_url, spec.test_path)
|
| 669 |
+
if slug in seen:
|
| 670 |
+
print(f"[{i:03d}] SKIP duplicate: {slug}")
|
| 671 |
+
continue
|
| 672 |
+
seen.add(slug)
|
| 673 |
+
|
| 674 |
+
dest = BASE_DIR / slug
|
| 675 |
+
dest.mkdir(parents=True, exist_ok=True)
|
| 676 |
+
|
| 677 |
+
print(f"\n[{i:03d}/{len(REPOS)}] {slug} [{spec.difficulty.upper()} | {spec.flake_category}]")
|
| 678 |
+
|
| 679 |
+
if not _clone_and_checkout(spec.repo_url, spec.sha, dest):
|
| 680 |
+
print(" SKIP — clone failed")
|
| 681 |
+
continue
|
| 682 |
+
|
| 683 |
+
_fetch_diff(spec.pr_url, dest)
|
| 684 |
+
_write_manifest(spec, dest)
|
| 685 |
+
curriculum[spec.difficulty].append(slug)
|
| 686 |
+
success += 1
|
| 687 |
+
time.sleep(0.3)
|
| 688 |
+
|
| 689 |
+
# Write curriculum labels
|
| 690 |
+
labels = BASE_DIR / "curriculum_labels.txt"
|
| 691 |
+
with labels.open("w", encoding="utf-8") as f:
|
| 692 |
+
f.write("# FlakeForge IDoFT Curriculum Labels\n")
|
| 693 |
+
f.write("# Train in order: EASY -> MEDIUM -> HARD\n")
|
| 694 |
+
f.write("# Format: <difficulty> <TAB> <repo_slug>\n\n")
|
| 695 |
+
for level in ("easy", "medium", "hard"):
|
| 696 |
+
f.write(f"# ── {level.upper()} ({len(curriculum[level])}) ─────────────────────\n")
|
| 697 |
+
for slug in curriculum[level]:
|
| 698 |
+
f.write(f"{level}\t{slug}\n")
|
| 699 |
+
f.write("\n")
|
| 700 |
+
|
| 701 |
+
print(f"\n{'='*60}")
|
| 702 |
+
print(f"Done! {success}/{len(REPOS)} repos processed.")
|
| 703 |
+
print(f" Easy: {len(curriculum['easy'])}")
|
| 704 |
+
print(f" Medium: {len(curriculum['medium'])}")
|
| 705 |
+
print(f" Hard: {len(curriculum['hard'])}")
|
| 706 |
+
print(f"Manifests: {BASE_DIR}")
|
| 707 |
+
print(f"Curriculum: {labels}")
|
| 708 |
+
|
| 709 |
+
|
| 710 |
+
if __name__ == "__main__":
|
| 711 |
+
main()
|
client.py
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""V3 FlakeForge Client — OpenEnv client for the unified agent architecture.
|
| 2 |
+
|
| 3 |
+
Changes from V2:
|
| 4 |
+
- No judge calls
|
| 5 |
+
- No hypothesis gating
|
| 6 |
+
- Unified observe → generate → step loop
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
from __future__ import annotations
|
| 10 |
+
|
| 11 |
+
import asyncio
|
| 12 |
+
import json
|
| 13 |
+
import os
|
| 14 |
+
from typing import Any, Dict, Optional
|
| 15 |
+
|
| 16 |
+
try:
|
| 17 |
+
from models import FlakeForgeAction, FlakeForgeObservation
|
| 18 |
+
from agent.unified_agent import (
|
| 19 |
+
UnifiedFlakeForgeAgent,
|
| 20 |
+
extract_think,
|
| 21 |
+
extract_patch,
|
| 22 |
+
extract_category_from_think,
|
| 23 |
+
extract_confidence_from_think,
|
| 24 |
+
)
|
| 25 |
+
except ImportError:
|
| 26 |
+
from .models import FlakeForgeAction, FlakeForgeObservation
|
| 27 |
+
from .agent.unified_agent import (
|
| 28 |
+
UnifiedFlakeForgeAgent,
|
| 29 |
+
extract_think,
|
| 30 |
+
extract_patch,
|
| 31 |
+
extract_category_from_think,
|
| 32 |
+
extract_confidence_from_think,
|
| 33 |
+
)
|
| 34 |
+
|
| 35 |
+
try:
|
| 36 |
+
from utils.logger import get_logger
|
| 37 |
+
except ImportError:
|
| 38 |
+
try:
|
| 39 |
+
from .utils.logger import get_logger
|
| 40 |
+
except ImportError:
|
| 41 |
+
import logging
|
| 42 |
+
get_logger = lambda n, **kw: logging.getLogger(n)
|
| 43 |
+
|
| 44 |
+
logger = get_logger(__name__)
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
class FlakeForgeClient:
|
| 48 |
+
"""V3 client for communicating with the FlakeForge environment.
|
| 49 |
+
|
| 50 |
+
Wraps the unified agent and environment interaction into a
|
| 51 |
+
simple interface for both inference and training.
|
| 52 |
+
"""
|
| 53 |
+
|
| 54 |
+
def __init__(
|
| 55 |
+
self,
|
| 56 |
+
agent: Optional[UnifiedFlakeForgeAgent] = None,
|
| 57 |
+
env_url: Optional[str] = None,
|
| 58 |
+
) -> None:
|
| 59 |
+
self.agent = agent
|
| 60 |
+
self.env_url = env_url or os.environ.get("ENV_BASE_URL", "http://localhost:8080")
|
| 61 |
+
|
| 62 |
+
def generate_action(self, observation: FlakeForgeObservation) -> FlakeForgeAction:
|
| 63 |
+
"""Generate a unified action from an observation."""
|
| 64 |
+
if self.agent is None:
|
| 65 |
+
raise RuntimeError("No agent configured. Pass agent to constructor.")
|
| 66 |
+
return self.agent.generate(observation)
|
| 67 |
+
|
| 68 |
+
def parse_raw_response(self, raw_response: str) -> FlakeForgeAction:
|
| 69 |
+
"""Parse a raw model response into a FlakeForgeAction."""
|
| 70 |
+
think = extract_think(raw_response)
|
| 71 |
+
patch = extract_patch(raw_response)
|
| 72 |
+
|
| 73 |
+
return FlakeForgeAction(
|
| 74 |
+
raw_response=raw_response,
|
| 75 |
+
think_text=think,
|
| 76 |
+
patch_text=patch,
|
| 77 |
+
predicted_category=extract_category_from_think(think),
|
| 78 |
+
predicted_confidence=extract_confidence_from_think(think),
|
| 79 |
+
)
|
| 80 |
+
|
| 81 |
+
async def run_episode_remote(
|
| 82 |
+
self,
|
| 83 |
+
test_identifier: str,
|
| 84 |
+
repo_path: str,
|
| 85 |
+
max_steps: int = 8,
|
| 86 |
+
) -> Dict[str, Any]:
|
| 87 |
+
"""Run an episode against a remote FlakeForge environment server."""
|
| 88 |
+
try:
|
| 89 |
+
import httpx
|
| 90 |
+
except ImportError:
|
| 91 |
+
raise ImportError("Remote client requires: pip install httpx")
|
| 92 |
+
|
| 93 |
+
async with httpx.AsyncClient(
|
| 94 |
+
base_url=self.env_url, timeout=120.0
|
| 95 |
+
) as client:
|
| 96 |
+
# Reset
|
| 97 |
+
reset_response = await client.post(
|
| 98 |
+
"/reset",
|
| 99 |
+
json={"test_identifier": test_identifier, "repo_path": repo_path},
|
| 100 |
+
)
|
| 101 |
+
reset_data = reset_response.json()
|
| 102 |
+
observation = FlakeForgeObservation(**reset_data["observation"])
|
| 103 |
+
|
| 104 |
+
trajectory = []
|
| 105 |
+
total_reward = 0.0
|
| 106 |
+
|
| 107 |
+
for step in range(max_steps):
|
| 108 |
+
if reset_data.get("done", False) and step > 0:
|
| 109 |
+
break
|
| 110 |
+
|
| 111 |
+
# Generate action
|
| 112 |
+
action = self.generate_action(observation)
|
| 113 |
+
|
| 114 |
+
# Send to environment
|
| 115 |
+
step_response = await client.post(
|
| 116 |
+
"/step",
|
| 117 |
+
json={
|
| 118 |
+
"raw_response": action.raw_response,
|
| 119 |
+
"think_text": action.think_text,
|
| 120 |
+
"patch_text": action.patch_text,
|
| 121 |
+
"predicted_category": action.predicted_category,
|
| 122 |
+
"predicted_confidence": action.predicted_confidence,
|
| 123 |
+
},
|
| 124 |
+
)
|
| 125 |
+
step_data = step_response.json()
|
| 126 |
+
|
| 127 |
+
observation = FlakeForgeObservation(**step_data["observation"])
|
| 128 |
+
reward = step_data.get("reward", 0.0)
|
| 129 |
+
total_reward += reward
|
| 130 |
+
|
| 131 |
+
trajectory.append({
|
| 132 |
+
"step": step + 1,
|
| 133 |
+
"category": action.predicted_category,
|
| 134 |
+
"confidence": action.predicted_confidence,
|
| 135 |
+
"reward": reward,
|
| 136 |
+
"pass_rate": step_data.get("state", {}).get("current_pass_rate", 0.0),
|
| 137 |
+
"done": step_data.get("done", False),
|
| 138 |
+
})
|
| 139 |
+
|
| 140 |
+
if step_data.get("done", False):
|
| 141 |
+
break
|
| 142 |
+
|
| 143 |
+
return {
|
| 144 |
+
"trajectory": trajectory,
|
| 145 |
+
"total_reward": total_reward,
|
| 146 |
+
"steps": len(trajectory),
|
| 147 |
+
"final_pass_rate": observation.current_pass_rate,
|
| 148 |
+
}
|
inference.py
ADDED
|
@@ -0,0 +1,933 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Inference loop — unified agent with verifiable reward."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import asyncio
|
| 6 |
+
import argparse
|
| 7 |
+
import json
|
| 8 |
+
import os
|
| 9 |
+
import shutil
|
| 10 |
+
import traceback
|
| 11 |
+
import uuid
|
| 12 |
+
from pathlib import Path
|
| 13 |
+
from typing import Any, Dict, List, Optional
|
| 14 |
+
|
| 15 |
+
try:
|
| 16 |
+
from dotenv import load_dotenv
|
| 17 |
+
except Exception: # pragma: no cover
|
| 18 |
+
load_dotenv = None # type: ignore[assignment]
|
| 19 |
+
|
| 20 |
+
try:
|
| 21 |
+
from openenv.core import EnvClient
|
| 22 |
+
from openenv.core.client_types import StepResult
|
| 23 |
+
except Exception:
|
| 24 |
+
EnvClient = None # type: ignore[assignment]
|
| 25 |
+
StepResult = None # type: ignore[assignment]
|
| 26 |
+
|
| 27 |
+
try:
|
| 28 |
+
from models import FlakeForgeAction, FlakeForgeObservation, FlakeForgeState
|
| 29 |
+
from agent.unified_agent import UnifiedFlakeForgeAgent, build_unified_prompt
|
| 30 |
+
from server.FlakeForge_environment import FlakeForgeEnvironment
|
| 31 |
+
except ImportError:
|
| 32 |
+
from .models import FlakeForgeAction, FlakeForgeObservation, FlakeForgeState
|
| 33 |
+
from .agent.unified_agent import UnifiedFlakeForgeAgent, build_unified_prompt
|
| 34 |
+
from .server.FlakeForge_environment import FlakeForgeEnvironment
|
| 35 |
+
|
| 36 |
+
try:
|
| 37 |
+
from utils.logger import get_logger
|
| 38 |
+
except ImportError:
|
| 39 |
+
try:
|
| 40 |
+
from .utils.logger import get_logger
|
| 41 |
+
except ImportError:
|
| 42 |
+
import logging
|
| 43 |
+
get_logger = lambda n, **kw: logging.getLogger(n)
|
| 44 |
+
|
| 45 |
+
logger = get_logger(__name__, log_file=Path("outputs/inference.log"))
|
| 46 |
+
|
| 47 |
+
if load_dotenv is not None:
|
| 48 |
+
load_dotenv()
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
if EnvClient is not None:
|
| 52 |
+
class FlakeForgeEnvClient(EnvClient[FlakeForgeAction, FlakeForgeObservation, FlakeForgeState]):
|
| 53 |
+
"""Concrete OpenEnv client for FlakeForge's action/observation models."""
|
| 54 |
+
|
| 55 |
+
def _step_payload(self, action: FlakeForgeAction) -> Dict[str, Any]:
|
| 56 |
+
return action.model_dump()
|
| 57 |
+
|
| 58 |
+
def _parse_result(self, payload: Dict[str, Any]) -> Any:
|
| 59 |
+
observation_data = payload.get("observation", {})
|
| 60 |
+
observation = FlakeForgeObservation.model_validate(observation_data)
|
| 61 |
+
reward = payload.get("reward", getattr(observation, "reward", 0.0))
|
| 62 |
+
done = payload.get("done", getattr(observation, "done", False))
|
| 63 |
+
info = payload.get("info") or _info_from_observation(observation)
|
| 64 |
+
|
| 65 |
+
if StepResult is not None:
|
| 66 |
+
result = StepResult(
|
| 67 |
+
observation=observation,
|
| 68 |
+
reward=float(reward or 0.0),
|
| 69 |
+
done=bool(done),
|
| 70 |
+
)
|
| 71 |
+
else:
|
| 72 |
+
result = {
|
| 73 |
+
"observation": observation,
|
| 74 |
+
"reward": float(reward or 0.0),
|
| 75 |
+
"done": bool(done),
|
| 76 |
+
}
|
| 77 |
+
|
| 78 |
+
state = FlakeForgeState(
|
| 79 |
+
episode_id=str(getattr(observation, "episode_id", "")),
|
| 80 |
+
step_count=int(getattr(observation, "step", 0)),
|
| 81 |
+
done=bool(done),
|
| 82 |
+
current_pass_rate=float(getattr(observation, "current_pass_rate", 0.0)),
|
| 83 |
+
baseline_pass_rate=float(getattr(observation, "baseline_pass_rate", 0.0)),
|
| 84 |
+
)
|
| 85 |
+
setattr(result, "state", state)
|
| 86 |
+
setattr(result, "info", info)
|
| 87 |
+
return result
|
| 88 |
+
|
| 89 |
+
def _parse_state(self, payload: Dict[str, Any]) -> FlakeForgeState:
|
| 90 |
+
return FlakeForgeState.model_validate(payload)
|
| 91 |
+
else:
|
| 92 |
+
FlakeForgeEnvClient = None # type: ignore[assignment]
|
| 93 |
+
|
| 94 |
+
|
| 95 |
+
def _as_step_output_like(value: Any) -> Any:
|
| 96 |
+
"""Normalize env return values to a StepOutput-like object.
|
| 97 |
+
|
| 98 |
+
Supports both:
|
| 99 |
+
- OpenEnv client/server style return object with observation/reward/done/state/info
|
| 100 |
+
- Direct observation returns from local environment implementations
|
| 101 |
+
"""
|
| 102 |
+
if (
|
| 103 |
+
hasattr(value, "observation")
|
| 104 |
+
and hasattr(value, "done")
|
| 105 |
+
and hasattr(value, "state")
|
| 106 |
+
and hasattr(value, "info")
|
| 107 |
+
):
|
| 108 |
+
if not getattr(value, "info", None):
|
| 109 |
+
setattr(value, "info", _info_from_observation(value.observation))
|
| 110 |
+
return value
|
| 111 |
+
|
| 112 |
+
class _StepLike:
|
| 113 |
+
def __init__(self, source: Any) -> None:
|
| 114 |
+
observation = getattr(source, "observation", source)
|
| 115 |
+
self.observation = observation
|
| 116 |
+
self.reward = float(
|
| 117 |
+
getattr(source, "reward", getattr(observation, "reward", 0.0)) or 0.0
|
| 118 |
+
)
|
| 119 |
+
self.done = bool(getattr(source, "done", getattr(observation, "done", False)))
|
| 120 |
+
self.info = getattr(source, "info", {}) or _info_from_observation(observation)
|
| 121 |
+
|
| 122 |
+
self.state = getattr(source, "state", None) or FlakeForgeState(
|
| 123 |
+
episode_id=str(getattr(observation, "episode_id", "")),
|
| 124 |
+
step_count=int(getattr(observation, "step", 0)),
|
| 125 |
+
done=self.done,
|
| 126 |
+
current_pass_rate=float(getattr(observation, "current_pass_rate", 0.0)),
|
| 127 |
+
baseline_pass_rate=float(getattr(observation, "baseline_pass_rate", 0.0)),
|
| 128 |
+
regression_detected=False,
|
| 129 |
+
)
|
| 130 |
+
|
| 131 |
+
return _StepLike(value)
|
| 132 |
+
|
| 133 |
+
|
| 134 |
+
def _info_from_observation(observation: Any) -> Dict[str, Any]:
|
| 135 |
+
"""Recover step metadata carried on plain OpenEnv observations."""
|
| 136 |
+
info: Dict[str, Any] = {}
|
| 137 |
+
patch_result = getattr(observation, "patch_result", None)
|
| 138 |
+
if patch_result:
|
| 139 |
+
info["patch_result"] = patch_result
|
| 140 |
+
reward_breakdown = getattr(observation, "reward_breakdown", None)
|
| 141 |
+
if reward_breakdown:
|
| 142 |
+
info["reward_breakdown"] = reward_breakdown
|
| 143 |
+
done_reason = getattr(observation, "done_reason", None)
|
| 144 |
+
if done_reason:
|
| 145 |
+
info["done_reason"] = done_reason
|
| 146 |
+
return info
|
| 147 |
+
|
| 148 |
+
|
| 149 |
+
class LLMBackend:
|
| 150 |
+
"""LLM backend that calls OpenAI-compatible APIs."""
|
| 151 |
+
|
| 152 |
+
def __init__(
|
| 153 |
+
self,
|
| 154 |
+
model_name: Optional[str] = None,
|
| 155 |
+
api_base: Optional[str] = None,
|
| 156 |
+
api_key: Optional[str] = None,
|
| 157 |
+
max_tokens: Optional[int] = None,
|
| 158 |
+
temperature: Optional[float] = None,
|
| 159 |
+
) -> None:
|
| 160 |
+
self.model_name = model_name or os.environ.get("MODEL_NAME", "meta-llama/Llama-3.1-8B-Instruct")
|
| 161 |
+
self.api_base = api_base or os.environ.get("API_BASE_URL") or os.environ.get("OPENAI_API_BASE", "http://localhost:8000/v1")
|
| 162 |
+
self.api_key = api_key or os.environ.get("NVIDIA_API_KEY") or os.environ.get("OPENAI_API_KEY", "EMPTY")
|
| 163 |
+
|
| 164 |
+
self.max_tokens = int(max_tokens or os.environ.get("MAX_TOKENS", 4096))
|
| 165 |
+
self.temperature = float(temperature or os.environ.get("TEMPERATURE", 0.2))
|
| 166 |
+
|
| 167 |
+
def _openai_json_schema_response_format(self) -> Dict[str, Any]:
|
| 168 |
+
"""Strict response_format for OpenAI-compatible backends."""
|
| 169 |
+
return {
|
| 170 |
+
"type": "json_schema",
|
| 171 |
+
"json_schema": {
|
| 172 |
+
"name": "flakeforge_action",
|
| 173 |
+
"strict": True,
|
| 174 |
+
"schema": {
|
| 175 |
+
"type": "object",
|
| 176 |
+
"additionalProperties": False,
|
| 177 |
+
"required": ["think", "patch"],
|
| 178 |
+
"properties": {
|
| 179 |
+
"think": {
|
| 180 |
+
"type": "object",
|
| 181 |
+
"additionalProperties": False,
|
| 182 |
+
"required": ["claims", "confidence"],
|
| 183 |
+
"properties": {
|
| 184 |
+
"claims": {
|
| 185 |
+
"type": "array",
|
| 186 |
+
"minItems": 1,
|
| 187 |
+
"items": {
|
| 188 |
+
"type": "object",
|
| 189 |
+
"additionalProperties": True,
|
| 190 |
+
"required": [
|
| 191 |
+
"category",
|
| 192 |
+
"entity",
|
| 193 |
+
"location",
|
| 194 |
+
"polarity",
|
| 195 |
+
"reason",
|
| 196 |
+
],
|
| 197 |
+
"properties": {
|
| 198 |
+
"category": {
|
| 199 |
+
"type": "string",
|
| 200 |
+
"enum": [
|
| 201 |
+
"async_wait",
|
| 202 |
+
"concurrency",
|
| 203 |
+
"test_order_dependency",
|
| 204 |
+
"resource_leak",
|
| 205 |
+
"shared_state",
|
| 206 |
+
"network",
|
| 207 |
+
"platform_dependency",
|
| 208 |
+
"nondeterminism",
|
| 209 |
+
"import_side_effect",
|
| 210 |
+
"module_cache_pollution",
|
| 211 |
+
"fixture_scope_leak",
|
| 212 |
+
"mock_residue",
|
| 213 |
+
"unknown",
|
| 214 |
+
],
|
| 215 |
+
},
|
| 216 |
+
"entity": {"type": "string"},
|
| 217 |
+
"location": {"type": "string"},
|
| 218 |
+
"polarity": {"type": "string", "enum": ["present", "absent"]},
|
| 219 |
+
"reason": {"type": "string"},
|
| 220 |
+
},
|
| 221 |
+
},
|
| 222 |
+
},
|
| 223 |
+
"confidence": {"type": "number", "minimum": 0.0, "maximum": 1.0},
|
| 224 |
+
},
|
| 225 |
+
},
|
| 226 |
+
"patch": {
|
| 227 |
+
"type": "object",
|
| 228 |
+
"additionalProperties": False,
|
| 229 |
+
"required": ["hunks"],
|
| 230 |
+
"properties": {
|
| 231 |
+
"hunks": {
|
| 232 |
+
"type": "array",
|
| 233 |
+
"minItems": 1,
|
| 234 |
+
"items": {
|
| 235 |
+
"type": "object",
|
| 236 |
+
"additionalProperties": True,
|
| 237 |
+
"required": [
|
| 238 |
+
"file",
|
| 239 |
+
"search",
|
| 240 |
+
"replace",
|
| 241 |
+
],
|
| 242 |
+
"properties": {
|
| 243 |
+
"file": {"type": "string"},
|
| 244 |
+
"search": {"type": "string"},
|
| 245 |
+
"replace": {"type": "string"},
|
| 246 |
+
},
|
| 247 |
+
},
|
| 248 |
+
},
|
| 249 |
+
},
|
| 250 |
+
},
|
| 251 |
+
},
|
| 252 |
+
},
|
| 253 |
+
},
|
| 254 |
+
}
|
| 255 |
+
|
| 256 |
+
def generate(self, prompt: str, *, system_prompt: str) -> str:
|
| 257 |
+
"""Generate a completion using either OpenAI or Ollama natively."""
|
| 258 |
+
fallback_response = {
|
| 259 |
+
"think": {
|
| 260 |
+
"claims": [
|
| 261 |
+
{
|
| 262 |
+
"category": "unknown",
|
| 263 |
+
"entity": "",
|
| 264 |
+
"location": "",
|
| 265 |
+
"polarity": "present",
|
| 266 |
+
"reason": "LLM call failed before root cause could be verified.",
|
| 267 |
+
}
|
| 268 |
+
],
|
| 269 |
+
"confidence": 0.1,
|
| 270 |
+
},
|
| 271 |
+
"patch": {"hunks": []},
|
| 272 |
+
}
|
| 273 |
+
# Use native Ollama if configured
|
| 274 |
+
if os.environ.get("OLLAMA_API_KEY") or "11434" in self.api_base:
|
| 275 |
+
try:
|
| 276 |
+
import ollama
|
| 277 |
+
response = ollama.chat(
|
| 278 |
+
model=self.model_name,
|
| 279 |
+
messages=[
|
| 280 |
+
{"role": "system", "content": system_prompt},
|
| 281 |
+
{"role": "user", "content": prompt},
|
| 282 |
+
],
|
| 283 |
+
options={
|
| 284 |
+
"temperature": self.temperature,
|
| 285 |
+
"num_predict": self.max_tokens,
|
| 286 |
+
},
|
| 287 |
+
format="json",
|
| 288 |
+
)
|
| 289 |
+
return response["message"]["content"] or ""
|
| 290 |
+
except ImportError:
|
| 291 |
+
logger.warning("[INFERENCE] ollama package not found, falling back to OpenAI compatibility layer")
|
| 292 |
+
except Exception as e:
|
| 293 |
+
logger.error(f"[INFERENCE] Ollama call failed: {e}")
|
| 294 |
+
return json.dumps(fallback_response)
|
| 295 |
+
|
| 296 |
+
# Default to OpenAI compatibility layer
|
| 297 |
+
try:
|
| 298 |
+
import openai
|
| 299 |
+
client = openai.OpenAI(
|
| 300 |
+
base_url=self.api_base,
|
| 301 |
+
api_key=self.api_key,
|
| 302 |
+
)
|
| 303 |
+
request = {
|
| 304 |
+
"model": self.model_name,
|
| 305 |
+
"messages": [
|
| 306 |
+
{"role": "system", "content": system_prompt},
|
| 307 |
+
{"role": "user", "content": prompt},
|
| 308 |
+
],
|
| 309 |
+
"max_tokens": self.max_tokens,
|
| 310 |
+
"temperature": self.temperature,
|
| 311 |
+
"response_format": self._openai_json_schema_response_format(),
|
| 312 |
+
}
|
| 313 |
+
try:
|
| 314 |
+
response = client.chat.completions.create(**request)
|
| 315 |
+
except Exception as response_format_error:
|
| 316 |
+
logger.warning(
|
| 317 |
+
"[INFERENCE] response_format=json_schema unsupported by backend; retrying with json_object: %s",
|
| 318 |
+
response_format_error,
|
| 319 |
+
)
|
| 320 |
+
request["response_format"] = {"type": "json_object"}
|
| 321 |
+
try:
|
| 322 |
+
response = client.chat.completions.create(**request)
|
| 323 |
+
except Exception as json_object_error:
|
| 324 |
+
logger.warning(
|
| 325 |
+
"[INFERENCE] response_format=json_object unsupported by backend; retrying without it: %s",
|
| 326 |
+
json_object_error,
|
| 327 |
+
)
|
| 328 |
+
request.pop("response_format", None)
|
| 329 |
+
response = client.chat.completions.create(**request)
|
| 330 |
+
return response.choices[0].message.content or ""
|
| 331 |
+
except Exception as e:
|
| 332 |
+
logger.error(f"[INFERENCE] LLM call failed: {e} (base={self.api_base} model={self.model_name})")
|
| 333 |
+
return json.dumps(fallback_response)
|
| 334 |
+
|
| 335 |
+
|
| 336 |
+
def _build_default_runner(repo_path: str) -> Optional[Any]:
|
| 337 |
+
"""Wrap DockerTestRunner for FlakeForgeEnvironment (expects run_test)."""
|
| 338 |
+
try:
|
| 339 |
+
from server.docker_runner import DockerTestRunner
|
| 340 |
+
except Exception:
|
| 341 |
+
try:
|
| 342 |
+
from .server.docker_runner import DockerTestRunner
|
| 343 |
+
except Exception:
|
| 344 |
+
return None
|
| 345 |
+
|
| 346 |
+
base_runner = DockerTestRunner(repo_path)
|
| 347 |
+
|
| 348 |
+
class _RunnerAdapter:
|
| 349 |
+
def run_test(self, test_identifier: str):
|
| 350 |
+
return base_runner.run_test(test_identifier)
|
| 351 |
+
|
| 352 |
+
def run_single(self, test_identifier: str) -> Dict[str, Any]:
|
| 353 |
+
record = self.run_test(test_identifier)
|
| 354 |
+
return {
|
| 355 |
+
"passed": bool(record.passed),
|
| 356 |
+
"duration_ms": int(record.duration_ms),
|
| 357 |
+
"error_type": record.error_type,
|
| 358 |
+
"error_message": record.error_message,
|
| 359 |
+
"stderr": record.stderr_excerpt or "",
|
| 360 |
+
}
|
| 361 |
+
|
| 362 |
+
return _RunnerAdapter()
|
| 363 |
+
|
| 364 |
+
|
| 365 |
+
def _should_use_remote_env() -> bool:
|
| 366 |
+
return False
|
| 367 |
+
|
| 368 |
+
|
| 369 |
+
def _run_async(coro: Any) -> Any:
|
| 370 |
+
"""Run a coroutine in CLI and notebook contexts without masking real errors."""
|
| 371 |
+
try:
|
| 372 |
+
return asyncio.run(coro)
|
| 373 |
+
except RuntimeError as exc:
|
| 374 |
+
msg = str(exc)
|
| 375 |
+
if "asyncio.run() cannot be called from a running event loop" not in msg:
|
| 376 |
+
raise
|
| 377 |
+
|
| 378 |
+
loop = asyncio.get_event_loop()
|
| 379 |
+
return loop.run_until_complete(coro)
|
| 380 |
+
|
| 381 |
+
|
| 382 |
+
async def run_episode(
|
| 383 |
+
env: FlakeForgeEnvironment,
|
| 384 |
+
agent: UnifiedFlakeForgeAgent,
|
| 385 |
+
verbose: bool = True,
|
| 386 |
+
reset_kwargs: Optional[Dict[str, Any]] = None,
|
| 387 |
+
) -> Dict[str, Any]:
|
| 388 |
+
"""Run a single episode of the unified inference loop.
|
| 389 |
+
|
| 390 |
+
Returns:
|
| 391 |
+
Episode result dict with trajectory, rewards, and metadata.
|
| 392 |
+
"""
|
| 393 |
+
episode_result: Dict[str, Any] = {
|
| 394 |
+
"trajectory": [],
|
| 395 |
+
"total_reward": 0.0,
|
| 396 |
+
"steps": 0,
|
| 397 |
+
"final_pass_rate": 0.0,
|
| 398 |
+
"done_reason": "in_progress",
|
| 399 |
+
"reward_breakdown_history": [],
|
| 400 |
+
}
|
| 401 |
+
|
| 402 |
+
reset_result = env.reset(**(reset_kwargs or {}))
|
| 403 |
+
if asyncio.iscoroutine(reset_result):
|
| 404 |
+
reset_result = await reset_result
|
| 405 |
+
step_output = _as_step_output_like(reset_result)
|
| 406 |
+
observation = step_output.observation
|
| 407 |
+
|
| 408 |
+
if verbose:
|
| 409 |
+
logger.info(
|
| 410 |
+
"[EPISODE] START test=%s baseline_pass_rate=%.2f deep_signals=%s",
|
| 411 |
+
observation.test_identifier,
|
| 412 |
+
observation.baseline_pass_rate,
|
| 413 |
+
step_output.info.get("deep_signals", {}),
|
| 414 |
+
)
|
| 415 |
+
|
| 416 |
+
while not step_output.done:
|
| 417 |
+
action = agent.generate(observation)
|
| 418 |
+
|
| 419 |
+
if verbose:
|
| 420 |
+
logger.info(
|
| 421 |
+
"[EPISODE] STEP %d -> category=%s confidence=%.2f patch_len=%d",
|
| 422 |
+
observation.step + 1,
|
| 423 |
+
action.predicted_category,
|
| 424 |
+
action.predicted_confidence,
|
| 425 |
+
len(action.patch_text),
|
| 426 |
+
)
|
| 427 |
+
|
| 428 |
+
step_result = env.step(action)
|
| 429 |
+
if asyncio.iscoroutine(step_result):
|
| 430 |
+
step_result = await step_result
|
| 431 |
+
step_output = _as_step_output_like(step_result)
|
| 432 |
+
observation = step_output.observation
|
| 433 |
+
|
| 434 |
+
reward = step_output.reward
|
| 435 |
+
breakdown = step_output.info.get("reward_breakdown", {})
|
| 436 |
+
done = step_output.done
|
| 437 |
+
pass_rate_after = step_output.state.current_pass_rate
|
| 438 |
+
pass_rate_before = observation.baseline_pass_rate
|
| 439 |
+
|
| 440 |
+
logger.info(
|
| 441 |
+
f"[EPISODE] RESULT step={step_output.state.step_count} reward={reward:.4f} "
|
| 442 |
+
f"pass_rate={pass_rate_before:.2f}->{pass_rate_after:.2f} "
|
| 443 |
+
f"done={done} reason={step_output.info.get('done_reason', '')}"
|
| 444 |
+
)
|
| 445 |
+
if breakdown and verbose:
|
| 446 |
+
logger.info(f" Breakdown: {breakdown}")
|
| 447 |
+
|
| 448 |
+
if not step_output.info.get("patch_result", {}).get("success", False) and verbose:
|
| 449 |
+
logger.warning(f" [DEBUG] Patch failed to apply. Raw response excerpt: {action.raw_response[:200]}...")
|
| 450 |
+
|
| 451 |
+
step_data = {
|
| 452 |
+
"step": step_output.state.step_count,
|
| 453 |
+
"predicted_category": action.predicted_category,
|
| 454 |
+
"predicted_confidence": action.predicted_confidence,
|
| 455 |
+
"think_text": action.think_text[:500],
|
| 456 |
+
"patch_text": action.patch_text,
|
| 457 |
+
"patch_applied": step_output.info.get("patch_result", {}).get("success", False),
|
| 458 |
+
"reward": step_output.reward,
|
| 459 |
+
"reward_breakdown": step_output.info.get("reward_breakdown", {}),
|
| 460 |
+
"pass_rate": step_output.state.current_pass_rate,
|
| 461 |
+
"done": step_output.done,
|
| 462 |
+
}
|
| 463 |
+
episode_result["trajectory"].append(step_data)
|
| 464 |
+
episode_result["total_reward"] += step_output.reward
|
| 465 |
+
episode_result["reward_breakdown_history"].append(
|
| 466 |
+
step_output.info.get("reward_breakdown", {})
|
| 467 |
+
)
|
| 468 |
+
|
| 469 |
+
if verbose:
|
| 470 |
+
logger.info(
|
| 471 |
+
"[EPISODE] RESULT step=%d reward=%.4f pass_rate=%.2f->%.2f done=%s reason=%s",
|
| 472 |
+
step_data["step"],
|
| 473 |
+
step_output.reward,
|
| 474 |
+
observation.baseline_pass_rate,
|
| 475 |
+
step_output.state.current_pass_rate,
|
| 476 |
+
step_output.done,
|
| 477 |
+
step_output.info.get("done_reason", ""),
|
| 478 |
+
)
|
| 479 |
+
|
| 480 |
+
episode_result["steps"] = step_output.state.step_count
|
| 481 |
+
episode_result["final_pass_rate"] = step_output.state.current_pass_rate
|
| 482 |
+
episode_result["done_reason"] = step_output.info.get("done_reason", "unknown")
|
| 483 |
+
|
| 484 |
+
return episode_result
|
| 485 |
+
|
| 486 |
+
|
| 487 |
+
def run_inference(
|
| 488 |
+
repo_path: str,
|
| 489 |
+
test_identifier: str,
|
| 490 |
+
model_name: Optional[str] = None,
|
| 491 |
+
max_steps: Optional[int] = None,
|
| 492 |
+
num_runs: int = 20,
|
| 493 |
+
api_base: Optional[str] = None,
|
| 494 |
+
api_key: Optional[str] = None,
|
| 495 |
+
verbose: bool = True,
|
| 496 |
+
) -> Dict[str, Any]:
|
| 497 |
+
"""Run a FlakeForge inference episode.
|
| 498 |
+
|
| 499 |
+
Creates the environment and agent, runs an episode, and returns
|
| 500 |
+
the result dict with trajectory, rewards, and metadata.
|
| 501 |
+
"""
|
| 502 |
+
max_steps = int(max_steps or os.environ.get("INFERENCE_MAX_STEPS", 8))
|
| 503 |
+
backend = LLMBackend(
|
| 504 |
+
model_name=model_name,
|
| 505 |
+
api_base=api_base,
|
| 506 |
+
api_key=api_key,
|
| 507 |
+
)
|
| 508 |
+
|
| 509 |
+
agent = UnifiedFlakeForgeAgent(backend=backend)
|
| 510 |
+
|
| 511 |
+
if _should_use_remote_env():
|
| 512 |
+
env_url = (os.environ.get("ENV_BASE_URL") or "http://localhost:5000").strip()
|
| 513 |
+
if verbose:
|
| 514 |
+
logger.info("[INFERENCE] Using remote OpenEnv client at %s", env_url)
|
| 515 |
+
if FlakeForgeEnvClient is None:
|
| 516 |
+
raise RuntimeError("Remote execution requested but openenv.core.EnvClient is unavailable")
|
| 517 |
+
env = FlakeForgeEnvClient(base_url=env_url)
|
| 518 |
+
reset_payload = {
|
| 519 |
+
"repo_path": repo_path,
|
| 520 |
+
"test_identifier": test_identifier,
|
| 521 |
+
"max_steps": max_steps,
|
| 522 |
+
"num_runs": num_runs,
|
| 523 |
+
}
|
| 524 |
+
|
| 525 |
+
async def _run_remote() -> Dict[str, Any]:
|
| 526 |
+
connect = getattr(env, "connect", None)
|
| 527 |
+
if callable(connect):
|
| 528 |
+
maybe_connected = connect()
|
| 529 |
+
if asyncio.iscoroutine(maybe_connected):
|
| 530 |
+
await maybe_connected
|
| 531 |
+
try:
|
| 532 |
+
return await run_episode(
|
| 533 |
+
env,
|
| 534 |
+
agent,
|
| 535 |
+
verbose=verbose,
|
| 536 |
+
reset_kwargs=reset_payload,
|
| 537 |
+
)
|
| 538 |
+
finally:
|
| 539 |
+
close = getattr(env, "close", None)
|
| 540 |
+
if callable(close):
|
| 541 |
+
maybe_closed = close()
|
| 542 |
+
if asyncio.iscoroutine(maybe_closed):
|
| 543 |
+
await maybe_closed
|
| 544 |
+
|
| 545 |
+
result = _run_async(_run_remote())
|
| 546 |
+
else:
|
| 547 |
+
runner = _build_default_runner(repo_path)
|
| 548 |
+
if runner is None and verbose:
|
| 549 |
+
logger.warning("[INFERENCE] Could not create DockerTestRunner adapter; environment will use synthetic runs.")
|
| 550 |
+
|
| 551 |
+
# Create environment
|
| 552 |
+
env = FlakeForgeEnvironment(
|
| 553 |
+
repo_path=repo_path,
|
| 554 |
+
test_identifier=test_identifier,
|
| 555 |
+
max_steps=max_steps,
|
| 556 |
+
num_runs=num_runs,
|
| 557 |
+
runner=runner,
|
| 558 |
+
)
|
| 559 |
+
|
| 560 |
+
# Run episode
|
| 561 |
+
result = _run_async(
|
| 562 |
+
run_episode(
|
| 563 |
+
env,
|
| 564 |
+
agent,
|
| 565 |
+
verbose=verbose,
|
| 566 |
+
reset_kwargs={
|
| 567 |
+
"preflight_quick_runs": max(3, min(num_runs, 10)),
|
| 568 |
+
"preflight_confirm_runs": num_runs,
|
| 569 |
+
},
|
| 570 |
+
)
|
| 571 |
+
)
|
| 572 |
+
|
| 573 |
+
if verbose:
|
| 574 |
+
logger.info(
|
| 575 |
+
"[INFERENCE] COMPLETE steps=%d total_reward=%.4f final_pass_rate=%.2f reason=%s",
|
| 576 |
+
result["steps"],
|
| 577 |
+
result["total_reward"],
|
| 578 |
+
result["final_pass_rate"],
|
| 579 |
+
result["done_reason"],
|
| 580 |
+
)
|
| 581 |
+
|
| 582 |
+
return result
|
| 583 |
+
|
| 584 |
+
|
| 585 |
+
def _default_repo_path() -> str:
|
| 586 |
+
return os.environ.get("FF_REPO_PATH", str(Path("test_repos") / "timing_race_minimal"))
|
| 587 |
+
|
| 588 |
+
|
| 589 |
+
def _default_test_id() -> str:
|
| 590 |
+
return os.environ.get("FF_TEST_ID", "tests/test_flaky.py::test_fetch_should_complete")
|
| 591 |
+
|
| 592 |
+
|
| 593 |
+
def _default_seed_root() -> str:
|
| 594 |
+
env_root = os.environ.get("FF_SEED_ROOT")
|
| 595 |
+
if env_root:
|
| 596 |
+
return env_root
|
| 597 |
+
|
| 598 |
+
project_root = Path(__file__).resolve().parent
|
| 599 |
+
candidates = [
|
| 600 |
+
Path(r"C:\CodingNest\seed_repos\idoft"),
|
| 601 |
+
Path(r"C:\CodingNest\seed_repos\idof"),
|
| 602 |
+
Path("/CodingNest/seed_repos/idoft"),
|
| 603 |
+
Path("/CodingNest/seed_repos/idof"),
|
| 604 |
+
(project_root / ".." / "seed_repos" / "idoft"),
|
| 605 |
+
(project_root / ".." / "seed_repos" / "idof"),
|
| 606 |
+
(project_root / ".." / ".." / "seed_repos" / "idoft"),
|
| 607 |
+
(project_root / ".." / ".." / "seed_repos" / "idof"),
|
| 608 |
+
]
|
| 609 |
+
for candidate in candidates:
|
| 610 |
+
resolved = candidate.resolve()
|
| 611 |
+
if resolved.exists() and any(resolved.glob("*/flake_manifest.json")):
|
| 612 |
+
return str(resolved)
|
| 613 |
+
return str(candidates[0])
|
| 614 |
+
|
| 615 |
+
|
| 616 |
+
def _load_seed_cases(seed_root: str | Path) -> List[Dict[str, Any]]:
|
| 617 |
+
root = Path(seed_root).expanduser().resolve()
|
| 618 |
+
cases: List[Dict[str, Any]] = []
|
| 619 |
+
for manifest_path in sorted(root.glob("*/flake_manifest.json")):
|
| 620 |
+
try:
|
| 621 |
+
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
|
| 622 |
+
except Exception as exc:
|
| 623 |
+
logger.warning("[INFERENCE] Skipping unreadable manifest %s: %s", manifest_path, exc)
|
| 624 |
+
continue
|
| 625 |
+
|
| 626 |
+
test_id = manifest.get("flaky_test_path") or manifest.get("test_identifier")
|
| 627 |
+
if not test_id:
|
| 628 |
+
logger.warning("[INFERENCE] Skipping manifest without flaky_test_path: %s", manifest_path)
|
| 629 |
+
continue
|
| 630 |
+
|
| 631 |
+
cases.append({
|
| 632 |
+
"case_id": manifest_path.parent.name,
|
| 633 |
+
"repo_path": manifest_path.parent,
|
| 634 |
+
"manifest_path": manifest_path,
|
| 635 |
+
"test_id": str(test_id),
|
| 636 |
+
"repo_name": manifest.get("repo_name", manifest_path.parent.name),
|
| 637 |
+
"flake_category": manifest.get("flake_category", "UNKNOWN"),
|
| 638 |
+
"difficulty": manifest.get("difficulty", "medium"),
|
| 639 |
+
})
|
| 640 |
+
|
| 641 |
+
if not cases:
|
| 642 |
+
raise FileNotFoundError(f"No usable flake_manifest.json files found under {root}")
|
| 643 |
+
return cases
|
| 644 |
+
|
| 645 |
+
|
| 646 |
+
def _ignore_episode_copy(_: str, names: List[str]) -> set[str]:
|
| 647 |
+
ignored = {"__pycache__", ".pytest_cache", ".mypy_cache", ".ruff_cache"} & set(names)
|
| 648 |
+
ignored.update(name for name in names if name.endswith((".pyc", ".pyo")))
|
| 649 |
+
return ignored
|
| 650 |
+
|
| 651 |
+
|
| 652 |
+
def _ensure_case_backup(
|
| 653 |
+
case: Dict[str, Any],
|
| 654 |
+
*,
|
| 655 |
+
backup_root: str | Path,
|
| 656 |
+
enabled: bool = True,
|
| 657 |
+
) -> Optional[Path]:
|
| 658 |
+
"""Create a one-time immutable backup copy of an original seed repo."""
|
| 659 |
+
if not enabled:
|
| 660 |
+
return None
|
| 661 |
+
|
| 662 |
+
source = Path(case["repo_path"]).resolve()
|
| 663 |
+
root = Path(backup_root).expanduser().resolve()
|
| 664 |
+
backup_path = root / case["case_id"]
|
| 665 |
+
|
| 666 |
+
if backup_path.exists():
|
| 667 |
+
return backup_path
|
| 668 |
+
|
| 669 |
+
root.mkdir(parents=True, exist_ok=True)
|
| 670 |
+
tmp_path = root / f".{case['case_id']}.tmp-{uuid.uuid4().hex[:8]}"
|
| 671 |
+
shutil.copytree(source, tmp_path, ignore=_ignore_episode_copy)
|
| 672 |
+
tmp_path.replace(backup_path)
|
| 673 |
+
return backup_path
|
| 674 |
+
|
| 675 |
+
|
| 676 |
+
def _materialize_case_repo(
|
| 677 |
+
case: Dict[str, Any],
|
| 678 |
+
isolate: bool = True,
|
| 679 |
+
backup_path: Optional[Path] = None,
|
| 680 |
+
) -> Path:
|
| 681 |
+
repo_path = Path(case["repo_path"]).resolve()
|
| 682 |
+
if not isolate:
|
| 683 |
+
return repo_path
|
| 684 |
+
|
| 685 |
+
episode_root = Path(os.environ.get("FF_INFERENCE_REPO_ROOT", "outputs/inference_repos")).resolve()
|
| 686 |
+
episode_root.mkdir(parents=True, exist_ok=True)
|
| 687 |
+
worktree = episode_root / f"{case['case_id']}-{uuid.uuid4().hex[:8]}"
|
| 688 |
+
if worktree.exists():
|
| 689 |
+
shutil.rmtree(worktree)
|
| 690 |
+
shutil.copytree(backup_path or repo_path, worktree, ignore=_ignore_episode_copy)
|
| 691 |
+
return worktree
|
| 692 |
+
|
| 693 |
+
|
| 694 |
+
def _select_seed_cases(
|
| 695 |
+
cases: List[Dict[str, Any]],
|
| 696 |
+
*,
|
| 697 |
+
case: Optional[str] = None,
|
| 698 |
+
limit: Optional[int] = None,
|
| 699 |
+
) -> List[Dict[str, Any]]:
|
| 700 |
+
selected = cases
|
| 701 |
+
if case:
|
| 702 |
+
needle = case.lower()
|
| 703 |
+
selected = [
|
| 704 |
+
item for item in selected
|
| 705 |
+
if needle in item["case_id"].lower() or needle in str(item.get("repo_name", "")).lower()
|
| 706 |
+
]
|
| 707 |
+
if not selected:
|
| 708 |
+
raise ValueError(f"No seed case matched {case!r}")
|
| 709 |
+
|
| 710 |
+
if limit is not None:
|
| 711 |
+
selected = selected[:max(limit, 0)]
|
| 712 |
+
return selected
|
| 713 |
+
|
| 714 |
+
|
| 715 |
+
def run_seed_inference(
|
| 716 |
+
*,
|
| 717 |
+
seed_root: str | Path,
|
| 718 |
+
case: Optional[str],
|
| 719 |
+
limit: Optional[int],
|
| 720 |
+
isolate: bool,
|
| 721 |
+
backup: bool,
|
| 722 |
+
backup_root: str | Path,
|
| 723 |
+
model_name: Optional[str],
|
| 724 |
+
max_steps: Optional[int],
|
| 725 |
+
num_runs: int,
|
| 726 |
+
api_base: Optional[str],
|
| 727 |
+
api_key: Optional[str],
|
| 728 |
+
verbose: bool,
|
| 729 |
+
) -> Dict[str, Any]:
|
| 730 |
+
cases = _select_seed_cases(_load_seed_cases(seed_root), case=case, limit=limit)
|
| 731 |
+
results: List[Dict[str, Any]] = []
|
| 732 |
+
|
| 733 |
+
for idx, seed_case in enumerate(cases, start=1):
|
| 734 |
+
backup_path = _ensure_case_backup(seed_case, backup_root=backup_root, enabled=backup)
|
| 735 |
+
worktree = _materialize_case_repo(seed_case, isolate=isolate, backup_path=backup_path)
|
| 736 |
+
if verbose:
|
| 737 |
+
logger.info(
|
| 738 |
+
"[INFERENCE] Seed case %d/%d id=%s category=%s test=%s repo=%s backup=%s",
|
| 739 |
+
idx,
|
| 740 |
+
len(cases),
|
| 741 |
+
seed_case["case_id"],
|
| 742 |
+
seed_case["flake_category"],
|
| 743 |
+
seed_case["test_id"],
|
| 744 |
+
worktree,
|
| 745 |
+
backup_path or "<disabled>",
|
| 746 |
+
)
|
| 747 |
+
|
| 748 |
+
try:
|
| 749 |
+
result = run_inference(
|
| 750 |
+
repo_path=str(worktree),
|
| 751 |
+
test_identifier=seed_case["test_id"],
|
| 752 |
+
model_name=model_name,
|
| 753 |
+
max_steps=max_steps,
|
| 754 |
+
num_runs=num_runs,
|
| 755 |
+
api_base=api_base,
|
| 756 |
+
api_key=api_key,
|
| 757 |
+
verbose=verbose,
|
| 758 |
+
)
|
| 759 |
+
results.append({
|
| 760 |
+
"case_id": seed_case["case_id"],
|
| 761 |
+
"repo_name": seed_case["repo_name"],
|
| 762 |
+
"flake_category": seed_case["flake_category"],
|
| 763 |
+
"difficulty": seed_case["difficulty"],
|
| 764 |
+
"test_id": seed_case["test_id"],
|
| 765 |
+
"source_repo_path": str(seed_case["repo_path"]),
|
| 766 |
+
"backup_repo_path": str(backup_path) if backup_path else None,
|
| 767 |
+
"run_repo_path": str(worktree),
|
| 768 |
+
"result": result,
|
| 769 |
+
})
|
| 770 |
+
except Exception as exc:
|
| 771 |
+
logger.error("[INFERENCE] Seed case failed id=%s: %s", seed_case["case_id"], exc)
|
| 772 |
+
results.append({
|
| 773 |
+
"case_id": seed_case["case_id"],
|
| 774 |
+
"repo_name": seed_case["repo_name"],
|
| 775 |
+
"flake_category": seed_case["flake_category"],
|
| 776 |
+
"difficulty": seed_case["difficulty"],
|
| 777 |
+
"test_id": seed_case["test_id"],
|
| 778 |
+
"source_repo_path": str(seed_case["repo_path"]),
|
| 779 |
+
"backup_repo_path": str(backup_path) if backup_path else None,
|
| 780 |
+
"run_repo_path": str(worktree),
|
| 781 |
+
"error": type(exc).__name__,
|
| 782 |
+
"message": str(exc),
|
| 783 |
+
})
|
| 784 |
+
|
| 785 |
+
return {
|
| 786 |
+
"seed_root": str(Path(seed_root).resolve()),
|
| 787 |
+
"backup_root": str(Path(backup_root).resolve()) if backup else None,
|
| 788 |
+
"isolated": isolate,
|
| 789 |
+
"count": len(results),
|
| 790 |
+
"results": results,
|
| 791 |
+
}
|
| 792 |
+
|
| 793 |
+
|
| 794 |
+
def main() -> None:
|
| 795 |
+
parser = argparse.ArgumentParser(description="Run FlakeForge unified inference episode")
|
| 796 |
+
parser.add_argument("--repo-path", default=_default_repo_path(), help="Path to target repo")
|
| 797 |
+
parser.add_argument("--test-id", default=_default_test_id(), help="Target test identifier")
|
| 798 |
+
parser.add_argument(
|
| 799 |
+
"--seed-root",
|
| 800 |
+
default=None,
|
| 801 |
+
help=f"Run manifest-backed local inference for repos under this seed root (default candidate: {_default_seed_root()})",
|
| 802 |
+
)
|
| 803 |
+
parser.add_argument("--case", default=None, help="Substring filter for seed case directory/repo_name")
|
| 804 |
+
parser.add_argument("--limit", type=int, default=None, help="Max seed cases to run")
|
| 805 |
+
parser.add_argument("--list-cases", action="store_true", help="List seed cases from --seed-root and exit")
|
| 806 |
+
parser.add_argument(
|
| 807 |
+
"--no-isolation",
|
| 808 |
+
action="store_true",
|
| 809 |
+
help="Patch seed repos in place instead of copying to outputs/inference_repos first",
|
| 810 |
+
)
|
| 811 |
+
parser.add_argument(
|
| 812 |
+
"--backup-root",
|
| 813 |
+
default=os.environ.get("FF_SEED_BACKUP_ROOT", "outputs/seed_repo_backups"),
|
| 814 |
+
help="Where immutable seed repo backups are stored before local inference",
|
| 815 |
+
)
|
| 816 |
+
parser.add_argument(
|
| 817 |
+
"--no-backup",
|
| 818 |
+
action="store_true",
|
| 819 |
+
help="Disable seed repo backup creation. Not recommended for batch runs.",
|
| 820 |
+
)
|
| 821 |
+
parser.add_argument("--model", default=os.environ.get("MODEL_NAME"), help="LLM model name")
|
| 822 |
+
parser.add_argument("--max-steps", type=int, default=None, help="Max episode steps")
|
| 823 |
+
parser.add_argument("--num-runs", type=int, default=int(os.environ.get("NUM_RUNS", 10)), help="Repeated test runs per step")
|
| 824 |
+
parser.add_argument("--api-base", default=os.environ.get("API_BASE_URL") or os.environ.get("OPENAI_API_BASE"), help="OpenAI-compatible base URL")
|
| 825 |
+
parser.add_argument("--api-key", default=os.environ.get("NVIDIA_API_KEY") or os.environ.get("OPENAI_API_KEY"), help="API key")
|
| 826 |
+
parser.add_argument("--quiet", action="store_true", help="Disable verbose logging")
|
| 827 |
+
args = parser.parse_args()
|
| 828 |
+
|
| 829 |
+
try:
|
| 830 |
+
if args.seed_root or args.list_cases:
|
| 831 |
+
seed_root = args.seed_root or _default_seed_root()
|
| 832 |
+
if args.list_cases:
|
| 833 |
+
cases = _select_seed_cases(_load_seed_cases(seed_root), case=args.case, limit=args.limit)
|
| 834 |
+
result = {
|
| 835 |
+
"seed_root": str(Path(seed_root).resolve()),
|
| 836 |
+
"count": len(cases),
|
| 837 |
+
"cases": [
|
| 838 |
+
{
|
| 839 |
+
"case_id": item["case_id"],
|
| 840 |
+
"repo_name": item["repo_name"],
|
| 841 |
+
"flake_category": item["flake_category"],
|
| 842 |
+
"difficulty": item["difficulty"],
|
| 843 |
+
"test_id": item["test_id"],
|
| 844 |
+
"repo_path": str(item["repo_path"]),
|
| 845 |
+
}
|
| 846 |
+
for item in cases
|
| 847 |
+
],
|
| 848 |
+
}
|
| 849 |
+
else:
|
| 850 |
+
result = run_seed_inference(
|
| 851 |
+
seed_root=seed_root,
|
| 852 |
+
case=args.case,
|
| 853 |
+
limit=args.limit,
|
| 854 |
+
isolate=not args.no_isolation,
|
| 855 |
+
backup=not args.no_backup,
|
| 856 |
+
backup_root=args.backup_root,
|
| 857 |
+
model_name=args.model,
|
| 858 |
+
max_steps=args.max_steps,
|
| 859 |
+
num_runs=args.num_runs,
|
| 860 |
+
api_base=args.api_base,
|
| 861 |
+
api_key=args.api_key,
|
| 862 |
+
verbose=not args.quiet,
|
| 863 |
+
)
|
| 864 |
+
else:
|
| 865 |
+
result = run_inference(
|
| 866 |
+
repo_path=args.repo_path,
|
| 867 |
+
test_identifier=args.test_id,
|
| 868 |
+
model_name=args.model,
|
| 869 |
+
max_steps=args.max_steps,
|
| 870 |
+
num_runs=args.num_runs,
|
| 871 |
+
api_base=args.api_base,
|
| 872 |
+
api_key=args.api_key,
|
| 873 |
+
verbose=not args.quiet,
|
| 874 |
+
)
|
| 875 |
+
print(json.dumps(result, indent=2), flush=True)
|
| 876 |
+
except Exception as exc:
|
| 877 |
+
logger.error("[INFERENCE] FATAL: %s", exc)
|
| 878 |
+
traceback.print_exc()
|
| 879 |
+
raise SystemExit(1)
|
| 880 |
+
|
| 881 |
+
|
| 882 |
+
if __name__ == "__main__":
|
| 883 |
+
main()
|
| 884 |
+
|
| 885 |
+
|
| 886 |
+
def flakeforge_reward_fn(
|
| 887 |
+
prompts: list,
|
| 888 |
+
completions: list,
|
| 889 |
+
**kwargs: Any,
|
| 890 |
+
) -> list:
|
| 891 |
+
"""Reward function compatible with TRL's GRPOTrainer.
|
| 892 |
+
|
| 893 |
+
Takes prompts and completions (from the model), evaluates each
|
| 894 |
+
completion using the V3 reward architecture.
|
| 895 |
+
|
| 896 |
+
This is a simplified wrapper for training — the full environment
|
| 897 |
+
step is not used here. Instead, we parse the completion and
|
| 898 |
+
compute format + reasoning consistency rewards.
|
| 899 |
+
"""
|
| 900 |
+
from agent.unified_agent import (
|
| 901 |
+
extract_think,
|
| 902 |
+
extract_patch,
|
| 903 |
+
extract_category_from_think,
|
| 904 |
+
extract_confidence_from_think,
|
| 905 |
+
infer_category_from_patch,
|
| 906 |
+
)
|
| 907 |
+
from server.reward import (
|
| 908 |
+
compute_format_reward,
|
| 909 |
+
compute_reasoning_consistency,
|
| 910 |
+
)
|
| 911 |
+
|
| 912 |
+
rewards = []
|
| 913 |
+
for prompt, completion in zip(prompts, completions):
|
| 914 |
+
completion_text = completion if isinstance(completion, str) else str(completion)
|
| 915 |
+
|
| 916 |
+
action = FlakeForgeAction(
|
| 917 |
+
raw_response=completion_text,
|
| 918 |
+
think_text=extract_think(completion_text),
|
| 919 |
+
patch_text=extract_patch(completion_text),
|
| 920 |
+
predicted_category=extract_category_from_think(extract_think(completion_text)),
|
| 921 |
+
predicted_confidence=extract_confidence_from_think(extract_think(completion_text)),
|
| 922 |
+
)
|
| 923 |
+
|
| 924 |
+
format_score = compute_format_reward(action)
|
| 925 |
+
inferred_cat = infer_category_from_patch(action.patch_text)
|
| 926 |
+
consistency_score = compute_reasoning_consistency(
|
| 927 |
+
action.predicted_category, inferred_cat, action.think_text, action.patch_text
|
| 928 |
+
)
|
| 929 |
+
|
| 930 |
+
total = format_score * 1.0 + consistency_score * 0.5
|
| 931 |
+
rewards.append(total)
|
| 932 |
+
|
| 933 |
+
return rewards
|
models.py
ADDED
|
@@ -0,0 +1,358 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""FlakeForge data models — actions, observations, state, and reward breakdown."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import math
|
| 6 |
+
from collections import Counter
|
| 7 |
+
from typing import Any, Dict, List, Literal, Optional
|
| 8 |
+
|
| 9 |
+
from openenv.core.env_server.types import Action, Observation, State
|
| 10 |
+
from pydantic import BaseModel, Field, field_validator
|
| 11 |
+
from pydantic.dataclasses import dataclass
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
ROOT_CAUSE_TYPES = [
|
| 15 |
+
"async_wait",
|
| 16 |
+
"concurrency",
|
| 17 |
+
"test_order_dependency",
|
| 18 |
+
"resource_leak",
|
| 19 |
+
"shared_state",
|
| 20 |
+
"network",
|
| 21 |
+
"platform_dependency",
|
| 22 |
+
"nondeterminism",
|
| 23 |
+
"import_side_effect",
|
| 24 |
+
"module_cache_pollution",
|
| 25 |
+
"fixture_scope_leak",
|
| 26 |
+
"mock_residue",
|
| 27 |
+
"unknown",
|
| 28 |
+
]
|
| 29 |
+
|
| 30 |
+
RELATED_CATEGORIES = {
|
| 31 |
+
"async_wait": {"concurrency", "platform_dependency"},
|
| 32 |
+
"concurrency": {"async_wait", "shared_state"},
|
| 33 |
+
"test_order_dependency": {"shared_state", "resource_leak"},
|
| 34 |
+
"resource_leak": {"shared_state", "test_order_dependency"},
|
| 35 |
+
"shared_state": {"test_order_dependency", "resource_leak", "module_cache_pollution"},
|
| 36 |
+
"network": {"platform_dependency", "async_wait"},
|
| 37 |
+
"platform_dependency": {"network", "async_wait"},
|
| 38 |
+
"nondeterminism": {"concurrency", "async_wait"},
|
| 39 |
+
"import_side_effect": {"module_cache_pollution", "shared_state"},
|
| 40 |
+
"module_cache_pollution": {"import_side_effect", "shared_state"},
|
| 41 |
+
"fixture_scope_leak": {"shared_state", "test_order_dependency"},
|
| 42 |
+
"mock_residue": {"shared_state", "fixture_scope_leak"},
|
| 43 |
+
"unknown": set(),
|
| 44 |
+
}
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
ClaimPolarity = Literal["present", "absent"]
|
| 48 |
+
ClaimVerdict = Literal["confirmed", "inconclusive", "refuted", "unverified"]
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
class ThinkClaim(BaseModel):
|
| 52 |
+
"""One structured assertion about a potential flakiness root cause."""
|
| 53 |
+
|
| 54 |
+
claim_id: str = Field(description="Unique ID within this think block, e.g. 'c1'")
|
| 55 |
+
category: str = Field(description="Root cause category from ROOT_CAUSE_TYPES")
|
| 56 |
+
entity: str = Field(description="Name of the function/class/variable involved")
|
| 57 |
+
location: str = Field(
|
| 58 |
+
description="Fully-qualified location: 'path/to/file.py::ClassName.method_name'"
|
| 59 |
+
)
|
| 60 |
+
ast_node_type: str = Field(
|
| 61 |
+
default="",
|
| 62 |
+
description="Optional: expected libcst node type, e.g. 'FunctionDef', 'Decorator'",
|
| 63 |
+
)
|
| 64 |
+
polarity: ClaimPolarity = Field(
|
| 65 |
+
description="'present' = bug exists; 'absent' = bug was removed by the fix"
|
| 66 |
+
)
|
| 67 |
+
predicted_effect: str = Field(
|
| 68 |
+
default="",
|
| 69 |
+
description="One-sentence prediction of the expected pass-rate change after fix",
|
| 70 |
+
)
|
| 71 |
+
reason: str = Field(description="Short (≤40 words) causal justification")
|
| 72 |
+
|
| 73 |
+
verdict: ClaimVerdict = Field(default="unverified")
|
| 74 |
+
oracle_score: float = Field(default=0.0)
|
| 75 |
+
|
| 76 |
+
|
| 77 |
+
class StructuredThink(BaseModel):
|
| 78 |
+
"""Structured think block: claims list + overall confidence."""
|
| 79 |
+
|
| 80 |
+
claims: List[ThinkClaim] = Field(default_factory=list)
|
| 81 |
+
confidence: float = Field(default=0.0, ge=0.0, le=1.0)
|
| 82 |
+
format_penalty: float = Field(default=0.0, ge=-1.0, le=0.0)
|
| 83 |
+
|
| 84 |
+
@property
|
| 85 |
+
def primary_category(self) -> str:
|
| 86 |
+
if not self.claims:
|
| 87 |
+
return "unknown"
|
| 88 |
+
return self.claims[0].category
|
| 89 |
+
|
| 90 |
+
|
| 91 |
+
class PatchHunk(BaseModel):
|
| 92 |
+
"""One search/replace hunk expressed as structured fields."""
|
| 93 |
+
|
| 94 |
+
hunk_id: str = Field(description="Unique ID within this patch block, e.g. 'h1'")
|
| 95 |
+
file: str = Field(
|
| 96 |
+
description="Repo-relative path to the file being patched, e.g. 'pybrake/notifier.py'"
|
| 97 |
+
)
|
| 98 |
+
search: str = Field(
|
| 99 |
+
description="Exact lines to find in the file (copied verbatim, preserving indentation)"
|
| 100 |
+
)
|
| 101 |
+
replace: str = Field(
|
| 102 |
+
description="Lines that replace the search block (may be empty string to delete)"
|
| 103 |
+
)
|
| 104 |
+
rationale: str = Field(
|
| 105 |
+
default="",
|
| 106 |
+
description="One sentence explaining why this specific change fixes the root cause",
|
| 107 |
+
)
|
| 108 |
+
addresses_claim: str = Field(
|
| 109 |
+
default="",
|
| 110 |
+
description="claim_id from StructuredThink.claims that this hunk resolves",
|
| 111 |
+
)
|
| 112 |
+
|
| 113 |
+
applied: bool = False
|
| 114 |
+
apply_error: str = ""
|
| 115 |
+
|
| 116 |
+
|
| 117 |
+
class StructuredPatch(BaseModel):
|
| 118 |
+
"""Structured patch block: a list of hunks."""
|
| 119 |
+
|
| 120 |
+
hunks: List[PatchHunk] = Field(default_factory=list)
|
| 121 |
+
format_penalty: float = Field(default=0.0, ge=-1.0, le=0.0)
|
| 122 |
+
|
| 123 |
+
@property
|
| 124 |
+
def files_targeted(self) -> List[str]:
|
| 125 |
+
return list({h.file for h in self.hunks})
|
| 126 |
+
|
| 127 |
+
|
| 128 |
+
class FlakeForgeAction(Action):
|
| 129 |
+
"""Unified action: the agent produces structured think + patch JSON."""
|
| 130 |
+
|
| 131 |
+
raw_response: str = ""
|
| 132 |
+
think_text: str = ""
|
| 133 |
+
patch_text: str = ""
|
| 134 |
+
structured_think: Optional[StructuredThink] = None
|
| 135 |
+
structured_patch: Optional[StructuredPatch] = None
|
| 136 |
+
predicted_category: str = "unknown"
|
| 137 |
+
predicted_confidence: float = 0.0
|
| 138 |
+
|
| 139 |
+
action_type: str = "UNIFIED_PATCH"
|
| 140 |
+
parameters: Dict[str, Any] = Field(default_factory=dict)
|
| 141 |
+
|
| 142 |
+
|
| 143 |
+
@dataclass
|
| 144 |
+
class RunRecord:
|
| 145 |
+
passed: bool
|
| 146 |
+
duration_ms: int
|
| 147 |
+
error_type: Optional[str] = None
|
| 148 |
+
error_message: Optional[str] = None
|
| 149 |
+
stderr_excerpt: Optional[str] = None
|
| 150 |
+
|
| 151 |
+
def __post_init__(self) -> None:
|
| 152 |
+
if self.error_message:
|
| 153 |
+
self.error_message = self.error_message[:200]
|
| 154 |
+
if self.stderr_excerpt:
|
| 155 |
+
self.stderr_excerpt = self.stderr_excerpt[:500]
|
| 156 |
+
|
| 157 |
+
|
| 158 |
+
@dataclass
|
| 159 |
+
class FailurePattern:
|
| 160 |
+
pass_rate: float
|
| 161 |
+
most_common_error: Optional[str]
|
| 162 |
+
error_distribution: Dict[str, int]
|
| 163 |
+
duration_mean: float
|
| 164 |
+
duration_std: float
|
| 165 |
+
flakiness_score: float
|
| 166 |
+
|
| 167 |
+
|
| 168 |
+
@dataclass
|
| 169 |
+
class PatchRecord:
|
| 170 |
+
patch_text: str
|
| 171 |
+
target_files: List[str]
|
| 172 |
+
lines_changed: int
|
| 173 |
+
pass_rate_after: float
|
| 174 |
+
applied_successfully: bool = True
|
| 175 |
+
|
| 176 |
+
|
| 177 |
+
@dataclass
|
| 178 |
+
class ASTSummary:
|
| 179 |
+
functions: List[Dict[str, Any]]
|
| 180 |
+
classes: List[Dict[str, Any]]
|
| 181 |
+
imports: List[str]
|
| 182 |
+
global_vars: List[str]
|
| 183 |
+
threading_primitives: List[str]
|
| 184 |
+
external_calls: List[str]
|
| 185 |
+
|
| 186 |
+
|
| 187 |
+
class FlakeForgeObservation(Observation):
|
| 188 |
+
"""Observation with deep flakiness signals from SE research."""
|
| 189 |
+
|
| 190 |
+
episode_id: str
|
| 191 |
+
test_identifier: str
|
| 192 |
+
step: int
|
| 193 |
+
steps_remaining: int
|
| 194 |
+
|
| 195 |
+
test_function_source: str
|
| 196 |
+
source_under_test: str
|
| 197 |
+
relevant_imports: List[str] = Field(default_factory=list)
|
| 198 |
+
file_tree: List[str] = Field(default_factory=list)
|
| 199 |
+
async_markers: List[str] = Field(default_factory=list)
|
| 200 |
+
|
| 201 |
+
run_history: List[RunRecord] = Field(default_factory=list)
|
| 202 |
+
current_pass_rate: float = 0.0
|
| 203 |
+
baseline_pass_rate: float = 0.0
|
| 204 |
+
env_type: str = Field(
|
| 205 |
+
default="unknown",
|
| 206 |
+
description="Preflight classification: stable, flaky, deterministic_bug, infra_broken, unknown",
|
| 207 |
+
)
|
| 208 |
+
should_train: bool = Field(
|
| 209 |
+
default=True,
|
| 210 |
+
description="False when preflight says this environment should be skipped for flaky-fixer training",
|
| 211 |
+
)
|
| 212 |
+
preflight_result: Dict[str, Any] = Field(
|
| 213 |
+
default_factory=dict,
|
| 214 |
+
description="Three-stage Sanity/Determinism/Flakiness gate summary",
|
| 215 |
+
)
|
| 216 |
+
|
| 217 |
+
patches_applied: List[PatchRecord] = Field(default_factory=list)
|
| 218 |
+
total_diff_lines: int = 0
|
| 219 |
+
|
| 220 |
+
think_history: List[Dict[str, Any]] = Field(
|
| 221 |
+
default_factory=list,
|
| 222 |
+
description=(
|
| 223 |
+
"Per-step summary dicts: {step, categories, entities, reason_signatures, "
|
| 224 |
+
"oracle_score, pass_rate_after, reward}. Used by the reward system to "
|
| 225 |
+
"penalise repeated hypotheses and by the prompt builder to warn the agent."
|
| 226 |
+
),
|
| 227 |
+
)
|
| 228 |
+
|
| 229 |
+
module_cache_violations: List[str] = Field(
|
| 230 |
+
default_factory=list,
|
| 231 |
+
description="Files with @lru_cache, mutable defaults, global state mutations",
|
| 232 |
+
)
|
| 233 |
+
fixture_scope_risks: List[str] = Field(
|
| 234 |
+
default_factory=list,
|
| 235 |
+
description="Session/module scoped fixtures returning mutable objects without teardown",
|
| 236 |
+
)
|
| 237 |
+
mock_residue_sites: List[str] = Field(
|
| 238 |
+
default_factory=list,
|
| 239 |
+
description="Uncleaned patches (patch() without with-context or .stop())",
|
| 240 |
+
)
|
| 241 |
+
import_side_effect_files: List[str] = Field(
|
| 242 |
+
default_factory=list,
|
| 243 |
+
description="Top-level module code with non-constant expressions",
|
| 244 |
+
)
|
| 245 |
+
async_contamination_alive: bool = Field(
|
| 246 |
+
default=False,
|
| 247 |
+
description="Async tasks / threads survived past test boundary",
|
| 248 |
+
)
|
| 249 |
+
|
| 250 |
+
failure_frontier: str = Field(
|
| 251 |
+
default="",
|
| 252 |
+
description="Deepest user-code frame in failing stack trace",
|
| 253 |
+
)
|
| 254 |
+
call_chain_to_frontier: List[str] = Field(
|
| 255 |
+
default_factory=list,
|
| 256 |
+
description="Caller chain from test entry to failure site",
|
| 257 |
+
)
|
| 258 |
+
boundary_crossings: List[str] = Field(
|
| 259 |
+
default_factory=list,
|
| 260 |
+
description="HTTP/DB/queue/gRPC boundaries crossed in call chain",
|
| 261 |
+
)
|
| 262 |
+
|
| 263 |
+
order_dependency_detected: bool = Field(
|
| 264 |
+
default=False,
|
| 265 |
+
description="Reverse-order run produced different result",
|
| 266 |
+
)
|
| 267 |
+
infrastructure_sensitive: bool = Field(
|
| 268 |
+
default=False,
|
| 269 |
+
description="Chaos run changed outcome significantly",
|
| 270 |
+
)
|
| 271 |
+
|
| 272 |
+
causal_graph: Optional[Dict[str, Any]] = None
|
| 273 |
+
causal_hints: List[str] = Field(default_factory=list)
|
| 274 |
+
|
| 275 |
+
failure_pattern_summary: Optional[Dict[str, Any]] = None
|
| 276 |
+
duration_fingerprint: Optional[Dict[str, float]] = None
|
| 277 |
+
failing_stack_trace: str = ""
|
| 278 |
+
|
| 279 |
+
last_think_text: str = ""
|
| 280 |
+
last_patch_text: str = ""
|
| 281 |
+
last_reward: float = 0.0
|
| 282 |
+
reward_breakdown: Dict[str, float] = Field(default_factory=dict)
|
| 283 |
+
patch_result: Dict[str, Any] = Field(default_factory=dict)
|
| 284 |
+
done_reason: str = ""
|
| 285 |
+
reward: float = 0.0
|
| 286 |
+
done: bool = False
|
| 287 |
+
|
| 288 |
+
@field_validator("run_history")
|
| 289 |
+
@classmethod
|
| 290 |
+
def _limit_history(cls, value: List[RunRecord]) -> List[RunRecord]:
|
| 291 |
+
return value[-20:]
|
| 292 |
+
|
| 293 |
+
|
| 294 |
+
class FlakeForgeState(State):
|
| 295 |
+
"""Server-side episode state."""
|
| 296 |
+
|
| 297 |
+
episode_id: str
|
| 298 |
+
step_count: int
|
| 299 |
+
done: bool = False
|
| 300 |
+
current_pass_rate: float = 0.0
|
| 301 |
+
baseline_pass_rate: float = 0.0
|
| 302 |
+
regression_detected: bool = False
|
| 303 |
+
env_type: str = "unknown"
|
| 304 |
+
should_train: bool = True
|
| 305 |
+
|
| 306 |
+
|
| 307 |
+
@dataclass
|
| 308 |
+
class RewardBreakdown:
|
| 309 |
+
"""Multi-signal reward breakdown for transparency and debugging."""
|
| 310 |
+
format_reward: float = 0.0
|
| 311 |
+
compile_reward: float = 0.0
|
| 312 |
+
stability_reward: float = 0.0
|
| 313 |
+
causal_proximity_reward: float = 0.0
|
| 314 |
+
failure_entropy_reward: float = 0.0
|
| 315 |
+
anti_hack_penalty: float = 0.0
|
| 316 |
+
regression_penalty: float = 0.0
|
| 317 |
+
reasoning_consistency_reward: float = 0.0
|
| 318 |
+
oracle_reasoning_reward: float = 0.0
|
| 319 |
+
patch_validation_signal: float = 0.0
|
| 320 |
+
noop_patch_penalty: float = 0.0
|
| 321 |
+
think_history_penalty: float = 0.0
|
| 322 |
+
terminal_bonus: float = 0.0
|
| 323 |
+
total_reward: float = 0.0
|
| 324 |
+
|
| 325 |
+
def to_dict(self) -> Dict[str, float]:
|
| 326 |
+
return {
|
| 327 |
+
"format": self.format_reward,
|
| 328 |
+
"compile": self.compile_reward,
|
| 329 |
+
"stability": self.stability_reward,
|
| 330 |
+
"causal_proximity": self.causal_proximity_reward,
|
| 331 |
+
"failure_entropy": self.failure_entropy_reward,
|
| 332 |
+
"anti_hack": self.anti_hack_penalty,
|
| 333 |
+
"regression": self.regression_penalty,
|
| 334 |
+
"reasoning_consistency": self.reasoning_consistency_reward,
|
| 335 |
+
"oracle_reasoning": self.oracle_reasoning_reward,
|
| 336 |
+
"patch_validation": self.patch_validation_signal,
|
| 337 |
+
"noop_patch": self.noop_patch_penalty,
|
| 338 |
+
"think_history": self.think_history_penalty,
|
| 339 |
+
"terminal_bonus": self.terminal_bonus,
|
| 340 |
+
"total": self.total_reward,
|
| 341 |
+
}
|
| 342 |
+
|
| 343 |
+
|
| 344 |
+
def failure_mode_entropy(run_records: List[RunRecord]) -> float:
|
| 345 |
+
"""Shannon entropy of error types. Lower = more deterministic = better."""
|
| 346 |
+
errors = [r.error_type for r in run_records if not r.passed and r.error_type]
|
| 347 |
+
if not errors:
|
| 348 |
+
return 0.0
|
| 349 |
+
counts = Counter(errors)
|
| 350 |
+
total = len(errors)
|
| 351 |
+
entropy = -sum((c / total) * math.log2(c / total) for c in counts.values())
|
| 352 |
+
max_e = math.log2(len(counts)) if len(counts) > 1 else 1.0
|
| 353 |
+
return round(entropy / max_e, 4) if max_e > 0 else 0.0
|
| 354 |
+
|
| 355 |
+
|
| 356 |
+
# Backward compatible aliases for template-generated names.
|
| 357 |
+
FlakeforgeAction = FlakeForgeAction
|
| 358 |
+
FlakeforgeObservation = FlakeForgeObservation
|
openenv.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
spec_version: 1
|
| 2 |
+
name: FlakeForge
|
| 3 |
+
type: space
|
| 4 |
+
runtime: fastapi
|
| 5 |
+
app: server.app:app
|
| 6 |
+
port: 8000
|
| 7 |
+
|
openenv_FlakeForge.egg-info/PKG-INFO
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
Metadata-Version: 2.4
|
| 2 |
+
Name: openenv-FlakeForge
|
| 3 |
+
Version: 0.1.0
|
| 4 |
+
Summary: Flakeforge environment for OpenEnv
|
| 5 |
+
Requires-Python: >=3.10
|
| 6 |
+
Requires-Dist: openenv-core[core]>=0.2.2
|
| 7 |
+
Requires-Dist: fastapi>=0.115.0
|
| 8 |
+
Requires-Dist: uvicorn>=0.24.0
|
| 9 |
+
Requires-Dist: pydantic>=2.8.0
|
| 10 |
+
Requires-Dist: pytest>=8.3.0
|
| 11 |
+
Requires-Dist: libcst>=1.4.0
|
| 12 |
+
Requires-Dist: tree-sitter>=0.22.0
|
| 13 |
+
Requires-Dist: tree-sitter-python>=0.21.0
|
| 14 |
+
Requires-Dist: tree-sitter-javascript>=0.21.0
|
| 15 |
+
Requires-Dist: chromadb>=0.5.0
|
| 16 |
+
Requires-Dist: sentence-transformers>=3.0.0
|
| 17 |
+
Requires-Dist: openai>=2.32.0
|
| 18 |
+
Requires-Dist: ollama>=0.3.0
|
| 19 |
+
Provides-Extra: dev
|
| 20 |
+
Requires-Dist: pytest>=8.0.0; extra == "dev"
|
| 21 |
+
Requires-Dist: pytest-asyncio>=0.23.0; extra == "dev"
|
| 22 |
+
Requires-Dist: pytest-cov>=4.0.0; extra == "dev"
|
openenv_FlakeForge.egg-info/SOURCES.txt
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
README.md
|
| 2 |
+
__init__.py
|
| 3 |
+
build_idoft_dataset.py
|
| 4 |
+
client.py
|
| 5 |
+
inference.py
|
| 6 |
+
models.py
|
| 7 |
+
pyproject.toml
|
| 8 |
+
run_curriculum.py
|
| 9 |
+
./__init__.py
|
| 10 |
+
./_curate_and_install.py
|
| 11 |
+
./_tmp_inventory.py
|
| 12 |
+
./build_idoft_dataset.py
|
| 13 |
+
./client.py
|
| 14 |
+
./inference.py
|
| 15 |
+
./models.py
|
| 16 |
+
./push_space.py
|
| 17 |
+
./run_curriculum.py
|
| 18 |
+
./templates/index.html
|
| 19 |
+
openenv_FlakeForge.egg-info/PKG-INFO
|
| 20 |
+
openenv_FlakeForge.egg-info/SOURCES.txt
|
| 21 |
+
openenv_FlakeForge.egg-info/dependency_links.txt
|
| 22 |
+
openenv_FlakeForge.egg-info/entry_points.txt
|
| 23 |
+
openenv_FlakeForge.egg-info/requires.txt
|
| 24 |
+
openenv_FlakeForge.egg-info/top_level.txt
|
| 25 |
+
server/FlakeForge_environment.py
|
| 26 |
+
server/__init__.py
|
| 27 |
+
server/app.py
|
| 28 |
+
server/causal_graph.py
|
| 29 |
+
server/chaos_runner.py
|
| 30 |
+
server/deep_flakiness.py
|
| 31 |
+
server/docker_runner.py
|
| 32 |
+
server/oracle_engine.py
|
| 33 |
+
server/patch_applier.py
|
| 34 |
+
server/patch_validator.py
|
| 35 |
+
server/perf_sentinel.py
|
| 36 |
+
server/reward.py
|
| 37 |
+
server/state.py
|
| 38 |
+
server/tools.py
|
| 39 |
+
templates/index.html
|
| 40 |
+
tests/test_action_and_judge_regressions.py
|
| 41 |
+
tests/test_flakeforge_integration.py
|
openenv_FlakeForge.egg-info/dependency_links.txt
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
|
openenv_FlakeForge.egg-info/entry_points.txt
ADDED
|
@@ -0,0 +1,2 @@
|
|
|
|
|
|
|
|
|
|
| 1 |
+
[console_scripts]
|
| 2 |
+
server = FlakeForge.server.app:main
|
openenv_FlakeForge.egg-info/requires.txt
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
openenv-core[core]>=0.2.2
|
| 2 |
+
fastapi>=0.115.0
|
| 3 |
+
uvicorn>=0.24.0
|
| 4 |
+
pydantic>=2.8.0
|
| 5 |
+
pytest>=8.3.0
|
| 6 |
+
libcst>=1.4.0
|
| 7 |
+
tree-sitter>=0.22.0
|
| 8 |
+
tree-sitter-python>=0.21.0
|
| 9 |
+
tree-sitter-javascript>=0.21.0
|
| 10 |
+
chromadb>=0.5.0
|
| 11 |
+
sentence-transformers>=3.0.0
|
| 12 |
+
openai>=2.32.0
|
| 13 |
+
ollama>=0.3.0
|
| 14 |
+
|
| 15 |
+
[dev]
|
| 16 |
+
pytest>=8.0.0
|
| 17 |
+
pytest-asyncio>=0.23.0
|
| 18 |
+
pytest-cov>=4.0.0
|
openenv_FlakeForge.egg-info/top_level.txt
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
FlakeForge
|
push_space.py
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
def push_space():
|
| 2 |
+
from huggingface_hub import HfApi
|
| 3 |
+
api = HfApi()
|
| 4 |
+
|
| 5 |
+
api.upload_folder(
|
| 6 |
+
folder_path=".",
|
| 7 |
+
repo_id="Aayush5665/FlakeForge",
|
| 8 |
+
repo_type="space",
|
| 9 |
+
)
|
| 10 |
+
|
| 11 |
+
# after training finishes
|
| 12 |
+
push_space()
|
pyproject.toml
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Copyright (c) Meta Platforms, Inc. and affiliates.
|
| 2 |
+
# All rights reserved.
|
| 3 |
+
#
|
| 4 |
+
# This source code is licensed under the BSD-style license found in the
|
| 5 |
+
# LICENSE file in the root directory of this source tree.
|
| 6 |
+
|
| 7 |
+
[build-system]
|
| 8 |
+
requires = ["setuptools>=45", "wheel"]
|
| 9 |
+
build-backend = "setuptools.build_meta"
|
| 10 |
+
|
| 11 |
+
[project]
|
| 12 |
+
name = "openenv-FlakeForge"
|
| 13 |
+
version = "0.1.0"
|
| 14 |
+
description = "Flakeforge environment for OpenEnv"
|
| 15 |
+
requires-python = ">=3.10"
|
| 16 |
+
dependencies = [
|
| 17 |
+
"openenv-core[core]>=0.2.2",
|
| 18 |
+
"fastapi>=0.115.0",
|
| 19 |
+
"uvicorn>=0.24.0",
|
| 20 |
+
"pydantic>=2.8.0",
|
| 21 |
+
"pytest>=8.3.0",
|
| 22 |
+
"libcst>=1.4.0",
|
| 23 |
+
"tree-sitter>=0.22.0",
|
| 24 |
+
"tree-sitter-python>=0.21.0",
|
| 25 |
+
"tree-sitter-javascript>=0.21.0",
|
| 26 |
+
"chromadb>=0.5.0",
|
| 27 |
+
"sentence-transformers>=3.0.0",
|
| 28 |
+
"openai>=2.32.0",
|
| 29 |
+
"ollama>=0.3.0",
|
| 30 |
+
]
|
| 31 |
+
|
| 32 |
+
[project.optional-dependencies]
|
| 33 |
+
dev = [
|
| 34 |
+
"pytest>=8.0.0",
|
| 35 |
+
"pytest-asyncio>=0.23.0",
|
| 36 |
+
"pytest-cov>=4.0.0",
|
| 37 |
+
]
|
| 38 |
+
|
| 39 |
+
[project.scripts]
|
| 40 |
+
# Server entry point - enables running via: uv run --project . server
|
| 41 |
+
# or: python -m FlakeForge.server.app
|
| 42 |
+
server = "FlakeForge.server.app:main"
|
| 43 |
+
|
| 44 |
+
[tool.setuptools]
|
| 45 |
+
include-package-data = true
|
| 46 |
+
packages = ["FlakeForge", "FlakeForge.server"]
|
| 47 |
+
package-dir = { "FlakeForge" = ".", "FlakeForge.server" = "server" }
|
| 48 |
+
|
| 49 |
+
[tool.setuptools.package-data]
|
| 50 |
+
FlakeForge = ["templates/*.html"]
|
pytest.ini
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
[pytest]
|
| 2 |
+
markers =
|
| 3 |
+
integration: end-to-end docker-backed integration tests
|
| 4 |
+
asyncio_mode = auto
|
run_curriculum.py
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import json
|
| 2 |
+
import os
|
| 3 |
+
import subprocess
|
| 4 |
+
import sys
|
| 5 |
+
from datetime import datetime
|
| 6 |
+
from pathlib import Path
|
| 7 |
+
|
| 8 |
+
def main():
|
| 9 |
+
project_root = Path(__file__).parent
|
| 10 |
+
root = project_root / "test_repos" / "synthetic"
|
| 11 |
+
cases = []
|
| 12 |
+
|
| 13 |
+
for manifest_path in root.glob("*/flake_manifest.json"):
|
| 14 |
+
try:
|
| 15 |
+
with open(manifest_path, "r", encoding="utf-8") as f:
|
| 16 |
+
manifest = json.load(f)
|
| 17 |
+
cases.append({
|
| 18 |
+
"case_id": manifest_path.parent.name,
|
| 19 |
+
"difficulty": manifest.get("difficulty", "medium"),
|
| 20 |
+
})
|
| 21 |
+
except Exception as e:
|
| 22 |
+
print(f"Error reading {manifest_path}: {e}")
|
| 23 |
+
|
| 24 |
+
# Sort by difficulty: easy -> medium -> hard
|
| 25 |
+
difficulty_order = {"easy": 0, "medium": 1, "hard": 2}
|
| 26 |
+
cases.sort(key=lambda c: (difficulty_order.get(c["difficulty"], 99), c["case_id"]))
|
| 27 |
+
|
| 28 |
+
print(f"Found {len(cases)} synthetic repos.")
|
| 29 |
+
print("Running curriculum: Easy -> Medium -> Hard")
|
| 30 |
+
|
| 31 |
+
results_dir = project_root / "outputs" / "curriculum_results"
|
| 32 |
+
results_dir.mkdir(parents=True, exist_ok=True)
|
| 33 |
+
backup_root = project_root / "outputs" / "synthetic_original_backups"
|
| 34 |
+
worktree_root = project_root / "outputs" / "curriculum_worktrees"
|
| 35 |
+
backup_root.mkdir(parents=True, exist_ok=True)
|
| 36 |
+
worktree_root.mkdir(parents=True, exist_ok=True)
|
| 37 |
+
|
| 38 |
+
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
| 39 |
+
summary_file = results_dir / f"curriculum_run_{timestamp}.jsonl"
|
| 40 |
+
|
| 41 |
+
print(f"Results will be appended to: {summary_file}")
|
| 42 |
+
print(f"Original repo backups: {backup_root}")
|
| 43 |
+
print(f"Agent editable copies: {worktree_root}")
|
| 44 |
+
|
| 45 |
+
for i, case in enumerate(cases, 1):
|
| 46 |
+
print(f"\n{'='*80}")
|
| 47 |
+
print(f"[{i}/{len(cases)}] Running {case['case_id']} (Difficulty: {case['difficulty'].upper()})")
|
| 48 |
+
print(f"{'='*80}\n")
|
| 49 |
+
|
| 50 |
+
cmd = [
|
| 51 |
+
sys.executable, "inference.py",
|
| 52 |
+
"--seed-root", str(root),
|
| 53 |
+
"--case", case["case_id"],
|
| 54 |
+
"--backup-root", str(backup_root),
|
| 55 |
+
]
|
| 56 |
+
|
| 57 |
+
env = os.environ.copy()
|
| 58 |
+
env["USE_DOCKER_IMAGE"] = "0"
|
| 59 |
+
env["FF_INFERENCE_REPO_ROOT"] = str(worktree_root)
|
| 60 |
+
|
| 61 |
+
# Safety: never pass --no-isolation or --no-backup here. inference.py
|
| 62 |
+
# will patch only copied worktrees and keep original synthetic repos clean.
|
| 63 |
+
completed = subprocess.run(cmd, check=False, cwd=project_root, env=env)
|
| 64 |
+
with summary_file.open("a", encoding="utf-8") as f:
|
| 65 |
+
f.write(json.dumps({
|
| 66 |
+
"case_id": case["case_id"],
|
| 67 |
+
"difficulty": case["difficulty"],
|
| 68 |
+
"returncode": completed.returncode,
|
| 69 |
+
}) + "\n")
|
| 70 |
+
|
| 71 |
+
if __name__ == "__main__":
|
| 72 |
+
main()
|
server/FlakeForge_environment.py
ADDED
|
@@ -0,0 +1,1196 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""V3 FlakeForge Environment — Unified step loop.
|
| 2 |
+
|
| 3 |
+
Key changes from V2:
|
| 4 |
+
- No hypothesis gating — agent outputs think+patch directly
|
| 5 |
+
- No judge calls — reward is fully deterministic
|
| 6 |
+
- Deep flakiness signals injected into observation
|
| 7 |
+
- Free-form patch application via search/replace
|
| 8 |
+
- Single-step flow: observe → generate → patch → run → reward
|
| 9 |
+
"""
|
| 10 |
+
|
| 11 |
+
from __future__ import annotations
|
| 12 |
+
|
| 13 |
+
import ast
|
| 14 |
+
import math
|
| 15 |
+
import os
|
| 16 |
+
import re
|
| 17 |
+
import runpy
|
| 18 |
+
import traceback
|
| 19 |
+
import uuid
|
| 20 |
+
import warnings
|
| 21 |
+
from collections import Counter
|
| 22 |
+
from pathlib import Path
|
| 23 |
+
from typing import Any, Dict, List, Optional, Tuple
|
| 24 |
+
|
| 25 |
+
warnings.filterwarnings("ignore", category=SyntaxWarning)
|
| 26 |
+
|
| 27 |
+
from openenv.core.env_server.interfaces import Environment
|
| 28 |
+
|
| 29 |
+
try:
|
| 30 |
+
from models import (
|
| 31 |
+
FlakeForgeAction,
|
| 32 |
+
FlakeForgeObservation,
|
| 33 |
+
FlakeForgeState,
|
| 34 |
+
RunRecord,
|
| 35 |
+
PatchRecord,
|
| 36 |
+
RewardBreakdown,
|
| 37 |
+
failure_mode_entropy,
|
| 38 |
+
)
|
| 39 |
+
from server.state import EpisodeState
|
| 40 |
+
from server.deep_flakiness import (
|
| 41 |
+
build_deep_observation_signals,
|
| 42 |
+
extract_failure_frontier,
|
| 43 |
+
)
|
| 44 |
+
from server.patch_applier import restore_repo_files, write_validated_sources
|
| 45 |
+
from server.patch_validator import PatchValidator
|
| 46 |
+
from server.reward import compute_verifiable_reward
|
| 47 |
+
from server.oracle_engine import verify_structured_think
|
| 48 |
+
from server.causal_graph import CrossRepoGraphBuilder
|
| 49 |
+
try:
|
| 50 |
+
from server.tools import build_agent_targeting_hints
|
| 51 |
+
except ImportError:
|
| 52 |
+
def build_agent_targeting_hints(**_kwargs: Any) -> List[str]:
|
| 53 |
+
return []
|
| 54 |
+
from server.docker_runner import DockerTestRunner
|
| 55 |
+
except ImportError:
|
| 56 |
+
try:
|
| 57 |
+
from ..models import (
|
| 58 |
+
FlakeForgeAction,
|
| 59 |
+
FlakeForgeObservation,
|
| 60 |
+
FlakeForgeState,
|
| 61 |
+
RunRecord,
|
| 62 |
+
PatchRecord,
|
| 63 |
+
RewardBreakdown,
|
| 64 |
+
failure_mode_entropy,
|
| 65 |
+
)
|
| 66 |
+
from ..server.state import EpisodeState
|
| 67 |
+
from ..server.deep_flakiness import (
|
| 68 |
+
build_deep_observation_signals,
|
| 69 |
+
extract_failure_frontier,
|
| 70 |
+
)
|
| 71 |
+
from ..server.patch_applier import restore_repo_files, write_validated_sources
|
| 72 |
+
from ..server.patch_validator import PatchValidator
|
| 73 |
+
from ..server.reward import compute_verifiable_reward
|
| 74 |
+
from ..server.oracle_engine import verify_structured_think
|
| 75 |
+
from ..server.causal_graph import CrossRepoGraphBuilder
|
| 76 |
+
try:
|
| 77 |
+
from ..server.tools import build_agent_targeting_hints
|
| 78 |
+
except ImportError:
|
| 79 |
+
def build_agent_targeting_hints(**_kwargs: Any) -> List[str]:
|
| 80 |
+
return []
|
| 81 |
+
from ..server.docker_runner import DockerTestRunner
|
| 82 |
+
except (ImportError, ValueError):
|
| 83 |
+
from FlakeForge.models import (
|
| 84 |
+
FlakeForgeAction,
|
| 85 |
+
FlakeForgeObservation,
|
| 86 |
+
FlakeForgeState,
|
| 87 |
+
RunRecord,
|
| 88 |
+
PatchRecord,
|
| 89 |
+
RewardBreakdown,
|
| 90 |
+
failure_mode_entropy,
|
| 91 |
+
)
|
| 92 |
+
from FlakeForge.server.state import EpisodeState
|
| 93 |
+
from FlakeForge.server.deep_flakiness import (
|
| 94 |
+
build_deep_observation_signals,
|
| 95 |
+
extract_failure_frontier,
|
| 96 |
+
)
|
| 97 |
+
from FlakeForge.server.patch_applier import restore_repo_files, write_validated_sources
|
| 98 |
+
from FlakeForge.server.patch_validator import PatchValidator
|
| 99 |
+
from FlakeForge.server.reward import compute_verifiable_reward
|
| 100 |
+
from FlakeForge.server.oracle_engine import verify_structured_think
|
| 101 |
+
from FlakeForge.server.causal_graph import CrossRepoGraphBuilder
|
| 102 |
+
try:
|
| 103 |
+
from FlakeForge.server.tools import build_agent_targeting_hints
|
| 104 |
+
except ImportError:
|
| 105 |
+
def build_agent_targeting_hints(**_kwargs: Any) -> List[str]:
|
| 106 |
+
return []
|
| 107 |
+
from FlakeForge.server.docker_runner import DockerTestRunner
|
| 108 |
+
|
| 109 |
+
try:
|
| 110 |
+
from utils.logger import get_logger
|
| 111 |
+
except ImportError:
|
| 112 |
+
try:
|
| 113 |
+
from ..utils.logger import get_logger
|
| 114 |
+
except ImportError:
|
| 115 |
+
import logging
|
| 116 |
+
get_logger = lambda n, **kw: logging.getLogger(n)
|
| 117 |
+
|
| 118 |
+
logger = get_logger(__name__)
|
| 119 |
+
|
| 120 |
+
|
| 121 |
+
_INFRA_ERROR_TYPES = {
|
| 122 |
+
"ImportError",
|
| 123 |
+
"ModuleNotFoundError",
|
| 124 |
+
"SyntaxError",
|
| 125 |
+
"IndentationError",
|
| 126 |
+
"FileNotFoundError",
|
| 127 |
+
}
|
| 128 |
+
|
| 129 |
+
|
| 130 |
+
class FlakeForgeEnvironment(Environment[FlakeForgeAction, FlakeForgeObservation, FlakeForgeState]):
|
| 131 |
+
"""V3 RL environment for flaky test repair.
|
| 132 |
+
|
| 133 |
+
Unified step loop:
|
| 134 |
+
1. Build observation with deep flakiness signals
|
| 135 |
+
2. Agent generates <think> + <patch> in one forward pass
|
| 136 |
+
3. Apply search/replace patch atomically
|
| 137 |
+
4. Run test suite to verify fix
|
| 138 |
+
5. Compute 6-signal verifiable reward
|
| 139 |
+
6. Return observation with updated signals
|
| 140 |
+
"""
|
| 141 |
+
|
| 142 |
+
def __init__(
|
| 143 |
+
self,
|
| 144 |
+
repo_path: Optional[str] = None,
|
| 145 |
+
test_identifier: Optional[str] = None,
|
| 146 |
+
max_steps: int = 8,
|
| 147 |
+
num_runs: int = 10,
|
| 148 |
+
runner: Optional[Any] = None,
|
| 149 |
+
chaos_runner: Optional[Any] = None,
|
| 150 |
+
) -> None:
|
| 151 |
+
default_repo = os.environ.get("FF_REPO_PATH", str(Path("test_repos") / "timing_race_minimal"))
|
| 152 |
+
default_test = os.environ.get("FF_TEST_ID", "tests/test_flaky.py::test_fetch_should_complete")
|
| 153 |
+
|
| 154 |
+
self.repo_path = Path(repo_path or default_repo)
|
| 155 |
+
self.test_identifier = test_identifier or default_test
|
| 156 |
+
self.max_steps = max_steps
|
| 157 |
+
self.num_runs = num_runs
|
| 158 |
+
# Default to DockerTestRunner if no runner provided
|
| 159 |
+
self.runner = runner or DockerTestRunner(str(self.repo_path))
|
| 160 |
+
self.chaos_runner = chaos_runner
|
| 161 |
+
self._episode_state: Optional[EpisodeState] = None
|
| 162 |
+
self._openenv_state: Optional[FlakeForgeState] = None
|
| 163 |
+
# First successful reset() captures a full .py tree snapshot; every later reset() restores
|
| 164 |
+
# it so consecutive episodes (and GRPO group rollouts that reset each time) do not stack patches.
|
| 165 |
+
self._pristine_file_snapshots: Optional[Dict[str, str]] = None
|
| 166 |
+
|
| 167 |
+
def reset(
|
| 168 |
+
self,
|
| 169 |
+
seed: Optional[int] = None,
|
| 170 |
+
episode_id: Optional[str] = None,
|
| 171 |
+
**kwargs: Any,
|
| 172 |
+
) -> FlakeForgeObservation:
|
| 173 |
+
"""Initialize a new episode."""
|
| 174 |
+
del seed
|
| 175 |
+
|
| 176 |
+
# Allow remote clients to configure env at reset-time.
|
| 177 |
+
if "repo_path" in kwargs and kwargs["repo_path"]:
|
| 178 |
+
new_repo = Path(str(kwargs["repo_path"]))
|
| 179 |
+
if self.repo_path.resolve() != new_repo.resolve():
|
| 180 |
+
self._pristine_file_snapshots = None # different tree — re-baseline on next line
|
| 181 |
+
self.repo_path = new_repo
|
| 182 |
+
if "test_identifier" in kwargs and kwargs["test_identifier"]:
|
| 183 |
+
self.test_identifier = str(kwargs["test_identifier"])
|
| 184 |
+
if "max_steps" in kwargs and kwargs["max_steps"] is not None:
|
| 185 |
+
self.max_steps = int(kwargs["max_steps"])
|
| 186 |
+
if "num_runs" in kwargs and kwargs["num_runs"] is not None:
|
| 187 |
+
self.num_runs = int(kwargs["num_runs"])
|
| 188 |
+
|
| 189 |
+
episode_id = episode_id or str(uuid.uuid4())[:8]
|
| 190 |
+
logger.info("[ENV] RESET episode=%s test=%s", episode_id, self.test_identifier)
|
| 191 |
+
|
| 192 |
+
if self._pristine_file_snapshots:
|
| 193 |
+
try:
|
| 194 |
+
restore_repo_files(self.repo_path, self._pristine_file_snapshots)
|
| 195 |
+
logger.info(
|
| 196 |
+
"[ENV] Restored %d .py file(s) from pristine snapshot",
|
| 197 |
+
len(self._pristine_file_snapshots),
|
| 198 |
+
)
|
| 199 |
+
except Exception as exc:
|
| 200 |
+
logger.warning("[ENV] Pristine restore failed (non-fatal): %s", exc)
|
| 201 |
+
|
| 202 |
+
self._reset_demo_repo_if_present()
|
| 203 |
+
|
| 204 |
+
# Read source files
|
| 205 |
+
test_source, source_under_test = self._read_sources()
|
| 206 |
+
file_tree = self._build_file_tree()
|
| 207 |
+
|
| 208 |
+
# Three-stage gate: Sanity → Determinism → Flakiness.
|
| 209 |
+
# skip_preflight=True reuses cached baseline from the previous reset
|
| 210 |
+
# (used during GRPO rollouts where flakiness was already confirmed).
|
| 211 |
+
skip_preflight = bool(kwargs.get("skip_preflight", False))
|
| 212 |
+
if skip_preflight and self._episode_state is not None:
|
| 213 |
+
baseline_runs = list(self._episode_state.run_history)
|
| 214 |
+
baseline_pass_rate = self._episode_state.baseline_pass_rate
|
| 215 |
+
baseline_entropy = self._episode_state.baseline_entropy
|
| 216 |
+
preflight = {
|
| 217 |
+
"runs": baseline_runs,
|
| 218 |
+
"pass_rate": baseline_pass_rate,
|
| 219 |
+
"failure_entropy": baseline_entropy,
|
| 220 |
+
"env_type": self._episode_state.env_type or "flaky",
|
| 221 |
+
"should_train": True,
|
| 222 |
+
"summary": dict(self._episode_state.preflight_result)
|
| 223 |
+
if self._episode_state.preflight_result else {},
|
| 224 |
+
}
|
| 225 |
+
else:
|
| 226 |
+
preflight = self._preflight_gate(
|
| 227 |
+
quick_runs=int(kwargs.get("preflight_quick_runs", 5)),
|
| 228 |
+
confirm_runs=int(kwargs.get("preflight_confirm_runs", 10)),
|
| 229 |
+
drop_deterministic_bugs=bool(kwargs.get("drop_deterministic_bugs", True)),
|
| 230 |
+
)
|
| 231 |
+
baseline_runs = preflight["runs"]
|
| 232 |
+
baseline_pass_rate = preflight["pass_rate"]
|
| 233 |
+
baseline_entropy = preflight["failure_entropy"]
|
| 234 |
+
|
| 235 |
+
# Extract failing stack trace
|
| 236 |
+
failing_trace = ""
|
| 237 |
+
last_error_type = None
|
| 238 |
+
for r in baseline_runs:
|
| 239 |
+
if not r.passed:
|
| 240 |
+
failing_trace = r.stderr_excerpt or r.error_message or ""
|
| 241 |
+
last_error_type = r.error_type
|
| 242 |
+
break
|
| 243 |
+
|
| 244 |
+
# Build deep flakiness signals (AST-based, <5ms)
|
| 245 |
+
deep_signals = build_deep_observation_signals(self.repo_path)
|
| 246 |
+
|
| 247 |
+
# Extract causal frontier from stack trace
|
| 248 |
+
failure_frontier, call_chain, boundary_crossings = extract_failure_frontier(
|
| 249 |
+
failing_trace, self.repo_path
|
| 250 |
+
)
|
| 251 |
+
|
| 252 |
+
# Check order dependency (reverse run)
|
| 253 |
+
order_dep = self._check_order_dependency(baseline_pass_rate)
|
| 254 |
+
|
| 255 |
+
# Check infrastructure sensitivity (chaos run)
|
| 256 |
+
infra_sensitive = self._check_infrastructure_sensitivity(baseline_pass_rate)
|
| 257 |
+
|
| 258 |
+
# Build causal graph
|
| 259 |
+
causal_graph_data, causal_hints = self._build_causal_graph(test_source)
|
| 260 |
+
|
| 261 |
+
# Build additional file-targeting hints from stack trace/imports/deep signals.
|
| 262 |
+
targeting_hints = build_agent_targeting_hints(
|
| 263 |
+
repo_path=str(self.repo_path),
|
| 264 |
+
test_identifier=self.test_identifier,
|
| 265 |
+
failing_stack_trace=failing_trace,
|
| 266 |
+
source_under_test=source_under_test,
|
| 267 |
+
causal_frontier=failure_frontier,
|
| 268 |
+
deep_signals=deep_signals,
|
| 269 |
+
max_hints=8,
|
| 270 |
+
)
|
| 271 |
+
merged_hints = list(dict.fromkeys([*causal_hints, *targeting_hints]))[:10]
|
| 272 |
+
|
| 273 |
+
# Initialize state
|
| 274 |
+
self._episode_state = EpisodeState(
|
| 275 |
+
episode_id=episode_id,
|
| 276 |
+
test_identifier=self.test_identifier,
|
| 277 |
+
repo_path=str(self.repo_path),
|
| 278 |
+
max_steps=self.max_steps,
|
| 279 |
+
original_test_source=test_source,
|
| 280 |
+
original_source_under_test=source_under_test,
|
| 281 |
+
current_test_source=test_source,
|
| 282 |
+
current_source_under_test=source_under_test,
|
| 283 |
+
run_history=baseline_runs,
|
| 284 |
+
baseline_pass_rate=baseline_pass_rate,
|
| 285 |
+
current_pass_rate=baseline_pass_rate,
|
| 286 |
+
baseline_entropy=baseline_entropy,
|
| 287 |
+
env_type=preflight["env_type"],
|
| 288 |
+
should_train=preflight["should_train"],
|
| 289 |
+
preflight_result=preflight["summary"],
|
| 290 |
+
failing_stack_trace=failing_trace,
|
| 291 |
+
last_error_type=last_error_type,
|
| 292 |
+
failure_frontier=failure_frontier,
|
| 293 |
+
call_chain_to_frontier=call_chain,
|
| 294 |
+
boundary_crossings=boundary_crossings,
|
| 295 |
+
order_dependency_detected=order_dep,
|
| 296 |
+
infrastructure_sensitive=infra_sensitive,
|
| 297 |
+
causal_graph=causal_graph_data,
|
| 298 |
+
causal_hints=merged_hints,
|
| 299 |
+
file_tree=file_tree,
|
| 300 |
+
**deep_signals,
|
| 301 |
+
)
|
| 302 |
+
|
| 303 |
+
observation = self._build_observation()
|
| 304 |
+
observation.reward = 0.0
|
| 305 |
+
observation.done = not preflight["should_train"]
|
| 306 |
+
self._openenv_state = FlakeForgeState(
|
| 307 |
+
episode_id=episode_id,
|
| 308 |
+
step_count=0,
|
| 309 |
+
done=not preflight["should_train"],
|
| 310 |
+
current_pass_rate=baseline_pass_rate,
|
| 311 |
+
baseline_pass_rate=baseline_pass_rate,
|
| 312 |
+
env_type=preflight["env_type"],
|
| 313 |
+
should_train=preflight["should_train"],
|
| 314 |
+
)
|
| 315 |
+
if not preflight["should_train"]:
|
| 316 |
+
self._episode_state.done = True
|
| 317 |
+
self._episode_state.last_done_reason = preflight["summary"]["reason"]
|
| 318 |
+
observation.done = True
|
| 319 |
+
observation.done_reason = self._episode_state.last_done_reason
|
| 320 |
+
|
| 321 |
+
if self._pristine_file_snapshots is None:
|
| 322 |
+
self._pristine_file_snapshots = dict(self._collect_sources())
|
| 323 |
+
logger.info(
|
| 324 |
+
"[ENV] Recorded pristine snapshot of %d .py file(s) for future resets",
|
| 325 |
+
len(self._pristine_file_snapshots),
|
| 326 |
+
)
|
| 327 |
+
return observation
|
| 328 |
+
|
| 329 |
+
def step(
|
| 330 |
+
self,
|
| 331 |
+
action: FlakeForgeAction,
|
| 332 |
+
timeout_s: Optional[float] = None,
|
| 333 |
+
**kwargs: Any,
|
| 334 |
+
) -> FlakeForgeObservation:
|
| 335 |
+
"""Execute one step of the unified agent loop."""
|
| 336 |
+
del timeout_s, kwargs
|
| 337 |
+
if self._episode_state is None:
|
| 338 |
+
raise RuntimeError("Environment not initialized. Call reset() first.")
|
| 339 |
+
if self._episode_state.done and not self._episode_state.should_train:
|
| 340 |
+
observation = self._build_observation()
|
| 341 |
+
observation.done = True
|
| 342 |
+
observation.done_reason = self._episode_state.last_done_reason or "preflight_rejected"
|
| 343 |
+
return observation
|
| 344 |
+
|
| 345 |
+
self._episode_state.step_count += 1
|
| 346 |
+
logger.info(
|
| 347 |
+
"[ENV] STEP %d/%d category=%s",
|
| 348 |
+
self._episode_state.step_count,
|
| 349 |
+
self.max_steps,
|
| 350 |
+
action.predicted_category,
|
| 351 |
+
)
|
| 352 |
+
|
| 353 |
+
# --- 1. Patch validation → apply (disk unchanged if invalid) ---
|
| 354 |
+
# Snapshot sources *before* any write: oracle + PatchValidator simulation.
|
| 355 |
+
pre_sources: Dict[str, str] = {}
|
| 356 |
+
if action.patch_text.strip():
|
| 357 |
+
pre_sources = self._collect_sources()
|
| 358 |
+
|
| 359 |
+
patch_result: Dict[str, Any] = {
|
| 360 |
+
"success": False,
|
| 361 |
+
"error": "empty_patch",
|
| 362 |
+
"files_modified": [],
|
| 363 |
+
"lines_changed": 0,
|
| 364 |
+
"diff": "",
|
| 365 |
+
"rejected_by_validator": False,
|
| 366 |
+
"validation_errors": [],
|
| 367 |
+
"validation_warnings": [],
|
| 368 |
+
"validation_score": None,
|
| 369 |
+
}
|
| 370 |
+
rollback_snapshots: Dict[str, str] = {}
|
| 371 |
+
if action.patch_text.strip():
|
| 372 |
+
validator = PatchValidator()
|
| 373 |
+
validation = validator.validate(
|
| 374 |
+
action.patch_text,
|
| 375 |
+
repo_path=self.repo_path,
|
| 376 |
+
pre_sources=pre_sources or None,
|
| 377 |
+
claims=(
|
| 378 |
+
action.structured_think.claims
|
| 379 |
+
if action.structured_think is not None and action.structured_think.claims
|
| 380 |
+
else None
|
| 381 |
+
),
|
| 382 |
+
default_target=self._resolve_default_target(),
|
| 383 |
+
failure_frontier=self._episode_state.failure_frontier,
|
| 384 |
+
call_chain=self._episode_state.call_chain_to_frontier,
|
| 385 |
+
)
|
| 386 |
+
rollback_snapshots = dict(
|
| 387 |
+
validation.simulate_result.get("original_sources")
|
| 388 |
+
or validation.simulate_result.get("rollback_snapshots")
|
| 389 |
+
or {}
|
| 390 |
+
)
|
| 391 |
+
if not validation.is_valid:
|
| 392 |
+
first_error = validation.errors[0] if validation.errors else "patch_validation_failed"
|
| 393 |
+
patch_result = {
|
| 394 |
+
"success": False,
|
| 395 |
+
"error": first_error,
|
| 396 |
+
"validation_errors": list(validation.errors),
|
| 397 |
+
"validation_warnings": list(validation.warnings),
|
| 398 |
+
"validation_score": validation.score,
|
| 399 |
+
"files_modified": [],
|
| 400 |
+
"lines_changed": 0,
|
| 401 |
+
"diff": "",
|
| 402 |
+
"noop": False,
|
| 403 |
+
"protected_file": False,
|
| 404 |
+
"fuzzy_applied": False,
|
| 405 |
+
"rejected_by_validator": True,
|
| 406 |
+
}
|
| 407 |
+
logger.warning(
|
| 408 |
+
"[ENV] PATCH VALIDATION FAILED errors=%s warnings=%s",
|
| 409 |
+
validation.errors,
|
| 410 |
+
validation.warnings,
|
| 411 |
+
)
|
| 412 |
+
else:
|
| 413 |
+
sim = validation.simulate_result
|
| 414 |
+
try:
|
| 415 |
+
write_validated_sources(
|
| 416 |
+
self.repo_path,
|
| 417 |
+
dict(sim.get("modified_sources") or {}),
|
| 418 |
+
)
|
| 419 |
+
patch_result = {
|
| 420 |
+
"success": True,
|
| 421 |
+
"files_modified": list(sim.get("files_modified") or []),
|
| 422 |
+
"lines_changed": int(sim.get("lines_changed") or 0),
|
| 423 |
+
"hunks_applied": int(sim.get("hunks_applied") or 0),
|
| 424 |
+
"diff": sim.get("diff") or "",
|
| 425 |
+
"error": None,
|
| 426 |
+
"noop": bool(sim.get("noop", False)),
|
| 427 |
+
"protected_file": bool(sim.get("protected_file", False)),
|
| 428 |
+
"fuzzy_applied": bool(sim.get("fuzzy_applied", False)),
|
| 429 |
+
}
|
| 430 |
+
except Exception as exc:
|
| 431 |
+
if rollback_snapshots:
|
| 432 |
+
restore_repo_files(self.repo_path, rollback_snapshots)
|
| 433 |
+
patch_result = {
|
| 434 |
+
"success": False,
|
| 435 |
+
"files_modified": [],
|
| 436 |
+
"lines_changed": 0,
|
| 437 |
+
"hunks_applied": 0,
|
| 438 |
+
"diff": "",
|
| 439 |
+
"error": f"validated_write_failed: {exc}",
|
| 440 |
+
"noop": False,
|
| 441 |
+
"protected_file": False,
|
| 442 |
+
"fuzzy_applied": False,
|
| 443 |
+
"rolled_back": True,
|
| 444 |
+
}
|
| 445 |
+
patch_result["validation_errors"] = []
|
| 446 |
+
patch_result["validation_warnings"] = list(validation.warnings)
|
| 447 |
+
patch_result["validation_score"] = validation.score
|
| 448 |
+
patch_result["rejected_by_validator"] = False
|
| 449 |
+
logger.info(
|
| 450 |
+
"[ENV] PATCH success=%s files=%s lines=%d error=%s validation_score=%s",
|
| 451 |
+
patch_result["success"],
|
| 452 |
+
patch_result.get("files_modified", []),
|
| 453 |
+
patch_result.get("lines_changed", 0),
|
| 454 |
+
patch_result.get("error"),
|
| 455 |
+
validation.score,
|
| 456 |
+
)
|
| 457 |
+
|
| 458 |
+
# --- 2. Syntax check (sanity after apply); rollback if broken ---
|
| 459 |
+
syntax_error = None
|
| 460 |
+
if patch_result["success"]:
|
| 461 |
+
syntax_error = self._check_syntax(patch_result.get("files_modified", []))
|
| 462 |
+
patch_result["syntax_error"] = syntax_error
|
| 463 |
+
if syntax_error:
|
| 464 |
+
logger.warning("[ENV] SYNTAX ERROR after apply (rolling back): %s", syntax_error)
|
| 465 |
+
if rollback_snapshots:
|
| 466 |
+
try:
|
| 467 |
+
restore_repo_files(self.repo_path, rollback_snapshots)
|
| 468 |
+
except Exception as exc:
|
| 469 |
+
logger.error("[ENV] Rollback failed: %s", exc)
|
| 470 |
+
patch_result["success"] = False
|
| 471 |
+
patch_result["error"] = "syntax_error_after_apply"
|
| 472 |
+
patch_result["rolled_back"] = True
|
| 473 |
+
|
| 474 |
+
# --- 3. Run tests ---
|
| 475 |
+
post_runs: List[RunRecord] = []
|
| 476 |
+
post_run_dicts: List[Dict[str, Any]] = []
|
| 477 |
+
if patch_result["success"] and not syntax_error:
|
| 478 |
+
post_runs = self._run_tests(self.num_runs)
|
| 479 |
+
post_run_dicts = [
|
| 480 |
+
{"passed": r.passed, "error_type": r.error_type, "duration_ms": r.duration_ms}
|
| 481 |
+
for r in post_runs
|
| 482 |
+
]
|
| 483 |
+
post_pass_rate = sum(1 for r in post_runs if r.passed) / max(len(post_runs), 1)
|
| 484 |
+
else:
|
| 485 |
+
post_pass_rate = self._episode_state.current_pass_rate
|
| 486 |
+
|
| 487 |
+
# --- 4. Regression check ---
|
| 488 |
+
regression_detected = post_pass_rate < self._episode_state.baseline_pass_rate - 0.1
|
| 489 |
+
if patch_result["success"] and not syntax_error and self.runner is not None:
|
| 490 |
+
try:
|
| 491 |
+
if hasattr(self.runner, "check_regressions"):
|
| 492 |
+
regression_detected = regression_detected or bool(
|
| 493 |
+
self.runner.check_regressions(self.test_identifier)
|
| 494 |
+
)
|
| 495 |
+
except Exception as exc:
|
| 496 |
+
logger.debug("[ENV] Regression check failed: %s", exc)
|
| 497 |
+
patch_result["regression_detected"] = regression_detected
|
| 498 |
+
|
| 499 |
+
# --- 5. Compute reward ---
|
| 500 |
+
pre_entropy = failure_mode_entropy(self._episode_state.run_history[-self.num_runs:])
|
| 501 |
+
observation = self._build_observation() # Build before reward for causal proximity
|
| 502 |
+
|
| 503 |
+
# Oracle: verify structured claims against pre/post patch sources.
|
| 504 |
+
oracle_score: Optional[float] = None
|
| 505 |
+
if action.structured_think is not None and action.structured_think.claims:
|
| 506 |
+
post_sources = self._collect_sources() # current disk = post-patch
|
| 507 |
+
try:
|
| 508 |
+
annotated_think, oracle_score = verify_structured_think(
|
| 509 |
+
action.structured_think,
|
| 510 |
+
pre_sources=pre_sources,
|
| 511 |
+
post_sources=post_sources,
|
| 512 |
+
patch_hunks=(
|
| 513 |
+
action.structured_patch.hunks
|
| 514 |
+
if action.structured_patch is not None
|
| 515 |
+
else ()
|
| 516 |
+
),
|
| 517 |
+
)
|
| 518 |
+
action = action.model_copy(update={"structured_think": annotated_think})
|
| 519 |
+
logger.info("[ENV] ORACLE score=%.3f claims=%d", oracle_score, len(annotated_think.claims))
|
| 520 |
+
except Exception as exc:
|
| 521 |
+
logger.warning("[ENV] Oracle verification failed (non-fatal): %s", exc)
|
| 522 |
+
|
| 523 |
+
reward_breakdown = compute_verifiable_reward(
|
| 524 |
+
action=action,
|
| 525 |
+
observation=observation,
|
| 526 |
+
patch_result=patch_result,
|
| 527 |
+
post_run_results=post_run_dicts,
|
| 528 |
+
baseline_pass_rate=self._episode_state.baseline_pass_rate,
|
| 529 |
+
pre_entropy=pre_entropy,
|
| 530 |
+
oracle_score=oracle_score,
|
| 531 |
+
regression_detected=regression_detected,
|
| 532 |
+
think_history=self._episode_state.step_think_history,
|
| 533 |
+
)
|
| 534 |
+
|
| 535 |
+
# --- 6. Update state ---
|
| 536 |
+
self._episode_state.current_pass_rate = post_pass_rate
|
| 537 |
+
self._episode_state.run_history.extend(post_runs)
|
| 538 |
+
self._episode_state.last_think_text = action.think_text
|
| 539 |
+
self._episode_state.last_patch_text = action.patch_text
|
| 540 |
+
self._episode_state.last_reward = reward_breakdown.total_reward
|
| 541 |
+
self._episode_state.last_reward_breakdown = reward_breakdown.to_dict()
|
| 542 |
+
self._episode_state.last_patch_result = patch_result
|
| 543 |
+
|
| 544 |
+
# Build and store per-step think summary for diversity tracking.
|
| 545 |
+
think_summary = self._build_think_summary(
|
| 546 |
+
action=action,
|
| 547 |
+
oracle_score=oracle_score,
|
| 548 |
+
pass_rate_after=post_pass_rate,
|
| 549 |
+
reward=reward_breakdown.total_reward,
|
| 550 |
+
)
|
| 551 |
+
self._episode_state.step_think_history.append(think_summary)
|
| 552 |
+
|
| 553 |
+
if patch_result["success"]:
|
| 554 |
+
self._episode_state.patches_applied.append(PatchRecord(
|
| 555 |
+
patch_text=action.patch_text,
|
| 556 |
+
target_files=patch_result.get("files_modified", []),
|
| 557 |
+
lines_changed=patch_result.get("lines_changed", 0),
|
| 558 |
+
pass_rate_after=post_pass_rate,
|
| 559 |
+
applied_successfully=True,
|
| 560 |
+
))
|
| 561 |
+
self._episode_state.total_diff_lines += patch_result.get("lines_changed", 0)
|
| 562 |
+
|
| 563 |
+
# Check for regression
|
| 564 |
+
if regression_detected:
|
| 565 |
+
self._episode_state.regression_detected = True
|
| 566 |
+
|
| 567 |
+
# Re-read modified sources
|
| 568 |
+
self._episode_state.current_test_source, self._episode_state.current_source_under_test = self._read_sources()
|
| 569 |
+
|
| 570 |
+
# Determine if episode is terminal
|
| 571 |
+
done = (
|
| 572 |
+
post_pass_rate >= 1.0 # Full stability achieved
|
| 573 |
+
or self._episode_state.step_count >= self.max_steps
|
| 574 |
+
)
|
| 575 |
+
self._episode_state.done = done
|
| 576 |
+
self._episode_state.last_done_reason = self._done_reason(post_pass_rate, done)
|
| 577 |
+
|
| 578 |
+
# Build final observation with updated signals
|
| 579 |
+
final_observation = self._build_observation()
|
| 580 |
+
final_observation.reward = reward_breakdown.total_reward
|
| 581 |
+
final_observation.done = done
|
| 582 |
+
final_observation.patch_result = patch_result
|
| 583 |
+
final_observation.done_reason = self._episode_state.last_done_reason
|
| 584 |
+
self._openenv_state = FlakeForgeState(
|
| 585 |
+
episode_id=self._episode_state.episode_id,
|
| 586 |
+
step_count=self._episode_state.step_count,
|
| 587 |
+
done=done,
|
| 588 |
+
current_pass_rate=post_pass_rate,
|
| 589 |
+
baseline_pass_rate=self._episode_state.baseline_pass_rate,
|
| 590 |
+
regression_detected=self._episode_state.regression_detected,
|
| 591 |
+
env_type=self._episode_state.env_type,
|
| 592 |
+
should_train=self._episode_state.should_train,
|
| 593 |
+
)
|
| 594 |
+
|
| 595 |
+
logger.info(
|
| 596 |
+
"[ENV] REWARD total=%.4f breakdown=%s done=%s pass_rate=%.2f->%.2f",
|
| 597 |
+
reward_breakdown.total_reward,
|
| 598 |
+
{k: round(v, 3) for k, v in reward_breakdown.to_dict().items()},
|
| 599 |
+
done,
|
| 600 |
+
self._episode_state.baseline_pass_rate,
|
| 601 |
+
post_pass_rate,
|
| 602 |
+
)
|
| 603 |
+
|
| 604 |
+
return final_observation
|
| 605 |
+
|
| 606 |
+
def _build_observation(self) -> FlakeForgeObservation:
|
| 607 |
+
"""Build a V3 observation from current state."""
|
| 608 |
+
if self._episode_state is None:
|
| 609 |
+
raise RuntimeError("State not initialized")
|
| 610 |
+
|
| 611 |
+
# Duration fingerprint
|
| 612 |
+
durations = [r.duration_ms for r in self._episode_state.run_history[-self.num_runs:]]
|
| 613 |
+
dur_mean = sum(durations) / max(len(durations), 1) if durations else 0
|
| 614 |
+
dur_std = (
|
| 615 |
+
math.sqrt(sum((d - dur_mean) ** 2 for d in durations) / max(len(durations), 1))
|
| 616 |
+
if durations else 0
|
| 617 |
+
)
|
| 618 |
+
|
| 619 |
+
return FlakeForgeObservation(
|
| 620 |
+
episode_id=self._episode_state.episode_id,
|
| 621 |
+
test_identifier=self._episode_state.test_identifier,
|
| 622 |
+
step=self._episode_state.step_count,
|
| 623 |
+
steps_remaining=self._episode_state.steps_remaining,
|
| 624 |
+
test_function_source=self._episode_state.current_test_source,
|
| 625 |
+
source_under_test=self._episode_state.current_source_under_test,
|
| 626 |
+
relevant_imports=self._extract_imports(self._episode_state.current_test_source),
|
| 627 |
+
file_tree=self._episode_state.file_tree,
|
| 628 |
+
run_history=self._episode_state.run_history[-20:],
|
| 629 |
+
current_pass_rate=self._episode_state.current_pass_rate,
|
| 630 |
+
baseline_pass_rate=self._episode_state.baseline_pass_rate,
|
| 631 |
+
env_type=self._episode_state.env_type,
|
| 632 |
+
should_train=self._episode_state.should_train,
|
| 633 |
+
preflight_result=dict(self._episode_state.preflight_result),
|
| 634 |
+
patches_applied=self._episode_state.patches_applied,
|
| 635 |
+
total_diff_lines=self._episode_state.total_diff_lines,
|
| 636 |
+
# V3 deep signals
|
| 637 |
+
module_cache_violations=self._episode_state.module_cache_violations,
|
| 638 |
+
fixture_scope_risks=self._episode_state.fixture_scope_risks,
|
| 639 |
+
mock_residue_sites=self._episode_state.mock_residue_sites,
|
| 640 |
+
import_side_effect_files=self._episode_state.import_side_effect_files,
|
| 641 |
+
async_contamination_alive=self._episode_state.async_contamination_alive,
|
| 642 |
+
# Causal frontier
|
| 643 |
+
failure_frontier=self._episode_state.failure_frontier,
|
| 644 |
+
call_chain_to_frontier=self._episode_state.call_chain_to_frontier,
|
| 645 |
+
boundary_crossings=self._episode_state.boundary_crossings,
|
| 646 |
+
# iDFlakies
|
| 647 |
+
order_dependency_detected=self._episode_state.order_dependency_detected,
|
| 648 |
+
infrastructure_sensitive=self._episode_state.infrastructure_sensitive,
|
| 649 |
+
# Causal graph
|
| 650 |
+
causal_graph=self._episode_state.causal_graph,
|
| 651 |
+
causal_hints=self._episode_state.causal_hints,
|
| 652 |
+
# Failure analysis
|
| 653 |
+
failing_stack_trace=self._episode_state.failing_stack_trace,
|
| 654 |
+
duration_fingerprint={"mean": dur_mean, "std": dur_std},
|
| 655 |
+
# Episode context
|
| 656 |
+
last_think_text=self._episode_state.last_think_text,
|
| 657 |
+
last_patch_text=self._episode_state.last_patch_text,
|
| 658 |
+
last_reward=self._episode_state.last_reward,
|
| 659 |
+
reward_breakdown=self._episode_state.last_reward_breakdown,
|
| 660 |
+
patch_result=self._episode_state.last_patch_result,
|
| 661 |
+
done_reason=self._episode_state.last_done_reason,
|
| 662 |
+
reward=self._episode_state.last_reward,
|
| 663 |
+
think_history=list(self._episode_state.step_think_history),
|
| 664 |
+
)
|
| 665 |
+
|
| 666 |
+
def _build_think_summary(
|
| 667 |
+
self,
|
| 668 |
+
action: FlakeForgeAction,
|
| 669 |
+
oracle_score: Optional[float],
|
| 670 |
+
pass_rate_after: float,
|
| 671 |
+
reward: float,
|
| 672 |
+
) -> Dict[str, Any]:
|
| 673 |
+
"""Build a compact think summary dict for history tracking."""
|
| 674 |
+
categories: List[str] = []
|
| 675 |
+
entities: List[str] = []
|
| 676 |
+
reason_signatures: List[str] = []
|
| 677 |
+
|
| 678 |
+
if action.structured_think and action.structured_think.claims:
|
| 679 |
+
for claim in action.structured_think.claims:
|
| 680 |
+
categories.append(claim.category)
|
| 681 |
+
if claim.entity:
|
| 682 |
+
entities.append(claim.entity)
|
| 683 |
+
if claim.reason:
|
| 684 |
+
reason_signatures.append(claim.reason[:35].lower().strip())
|
| 685 |
+
elif action.predicted_category:
|
| 686 |
+
categories = [action.predicted_category]
|
| 687 |
+
|
| 688 |
+
return {
|
| 689 |
+
"step": self._episode_state.step_count,
|
| 690 |
+
"categories": categories,
|
| 691 |
+
"entities": entities,
|
| 692 |
+
"reason_signatures": reason_signatures,
|
| 693 |
+
"oracle_score": round(oracle_score, 3) if oracle_score is not None else None,
|
| 694 |
+
"pass_rate_after": round(pass_rate_after, 3),
|
| 695 |
+
"reward": round(reward, 4),
|
| 696 |
+
}
|
| 697 |
+
|
| 698 |
+
def _preflight_gate(
|
| 699 |
+
self,
|
| 700 |
+
*,
|
| 701 |
+
quick_runs: int = 10,
|
| 702 |
+
confirm_runs: int = 20,
|
| 703 |
+
drop_deterministic_bugs: bool = True,
|
| 704 |
+
) -> Dict[str, Any]:
|
| 705 |
+
"""Classify environment before training: sanity → determinism → flakiness.
|
| 706 |
+
|
| 707 |
+
Pass rate alone is not enough: 0/N can be a deterministic bug, infra
|
| 708 |
+
breakage, or a hard flaky case where success was not observed yet.
|
| 709 |
+
This gate separates those cases using error consistency/entropy.
|
| 710 |
+
"""
|
| 711 |
+
quick_runs = max(1, quick_runs)
|
| 712 |
+
confirm_runs = max(1, confirm_runs)
|
| 713 |
+
|
| 714 |
+
sanity = self._run_tests(1)
|
| 715 |
+
runs: List[RunRecord] = list(sanity)
|
| 716 |
+
sanity_record = sanity[0] if sanity else RunRecord(
|
| 717 |
+
passed=False,
|
| 718 |
+
duration_ms=0,
|
| 719 |
+
error_type="RunnerError",
|
| 720 |
+
error_message="runner returned no result",
|
| 721 |
+
)
|
| 722 |
+
|
| 723 |
+
if self._is_infra_failure(sanity_record):
|
| 724 |
+
return self._preflight_result(
|
| 725 |
+
runs=runs,
|
| 726 |
+
env_type="infra_broken",
|
| 727 |
+
should_train=False,
|
| 728 |
+
reason="preflight_infra_broken",
|
| 729 |
+
stage="sanity",
|
| 730 |
+
quick_runs=quick_runs,
|
| 731 |
+
confirm_runs=confirm_runs,
|
| 732 |
+
)
|
| 733 |
+
|
| 734 |
+
# Stage 2: cheap deterministic baseline. We already ran one sanity pass.
|
| 735 |
+
remaining_quick = max(quick_runs - len(runs), 0)
|
| 736 |
+
if remaining_quick:
|
| 737 |
+
runs.extend(self._run_tests(remaining_quick))
|
| 738 |
+
|
| 739 |
+
quick_passes = sum(1 for r in runs[:quick_runs] if r.passed)
|
| 740 |
+
if quick_passes == quick_runs:
|
| 741 |
+
return self._preflight_result(
|
| 742 |
+
runs=runs[:quick_runs],
|
| 743 |
+
env_type="stable",
|
| 744 |
+
should_train=False,
|
| 745 |
+
reason="preflight_stable_pass",
|
| 746 |
+
stage="determinism",
|
| 747 |
+
quick_runs=quick_runs,
|
| 748 |
+
confirm_runs=confirm_runs,
|
| 749 |
+
)
|
| 750 |
+
if 0 < quick_passes < quick_runs:
|
| 751 |
+
return self._preflight_result(
|
| 752 |
+
runs=runs[:quick_runs],
|
| 753 |
+
env_type="flaky",
|
| 754 |
+
should_train=True,
|
| 755 |
+
reason="preflight_mixed_pass_fail",
|
| 756 |
+
stage="determinism",
|
| 757 |
+
quick_runs=quick_runs,
|
| 758 |
+
confirm_runs=confirm_runs,
|
| 759 |
+
)
|
| 760 |
+
|
| 761 |
+
# Stage 3: 0/N quick passes. Do not drop yet; confirm failure type.
|
| 762 |
+
runs.extend(self._run_tests(confirm_runs))
|
| 763 |
+
confirm_window = runs[quick_runs:quick_runs + confirm_runs]
|
| 764 |
+
confirm_passes = sum(1 for r in confirm_window if r.passed)
|
| 765 |
+
if confirm_passes > 0:
|
| 766 |
+
return self._preflight_result(
|
| 767 |
+
runs=runs,
|
| 768 |
+
env_type="flaky",
|
| 769 |
+
should_train=True,
|
| 770 |
+
reason="preflight_late_success_after_zero_quick_passes",
|
| 771 |
+
stage="flakiness_confirm",
|
| 772 |
+
quick_runs=quick_runs,
|
| 773 |
+
confirm_runs=confirm_runs,
|
| 774 |
+
)
|
| 775 |
+
|
| 776 |
+
failure_keys = self._failure_keys(runs)
|
| 777 |
+
unique_failures = set(failure_keys)
|
| 778 |
+
if len(unique_failures) <= 1:
|
| 779 |
+
return self._preflight_result(
|
| 780 |
+
runs=runs,
|
| 781 |
+
env_type="deterministic_bug",
|
| 782 |
+
should_train=not drop_deterministic_bugs,
|
| 783 |
+
reason=(
|
| 784 |
+
"preflight_deterministic_bug_dropped"
|
| 785 |
+
if drop_deterministic_bugs
|
| 786 |
+
else "preflight_deterministic_bug_labeled"
|
| 787 |
+
),
|
| 788 |
+
stage="flakiness_confirm",
|
| 789 |
+
quick_runs=quick_runs,
|
| 790 |
+
confirm_runs=confirm_runs,
|
| 791 |
+
)
|
| 792 |
+
|
| 793 |
+
return self._preflight_result(
|
| 794 |
+
runs=runs,
|
| 795 |
+
env_type="deterministic_bug",
|
| 796 |
+
should_train=not drop_deterministic_bugs,
|
| 797 |
+
reason=(
|
| 798 |
+
"preflight_deterministic_bug_dropped"
|
| 799 |
+
if drop_deterministic_bugs
|
| 800 |
+
else "preflight_deterministic_multi_error_labeled"
|
| 801 |
+
),
|
| 802 |
+
stage="flakiness_confirm",
|
| 803 |
+
quick_runs=quick_runs,
|
| 804 |
+
confirm_runs=confirm_runs,
|
| 805 |
+
)
|
| 806 |
+
|
| 807 |
+
def _preflight_result(
|
| 808 |
+
self,
|
| 809 |
+
*,
|
| 810 |
+
runs: List[RunRecord],
|
| 811 |
+
env_type: str,
|
| 812 |
+
should_train: bool,
|
| 813 |
+
reason: str,
|
| 814 |
+
stage: str,
|
| 815 |
+
quick_runs: int,
|
| 816 |
+
confirm_runs: int,
|
| 817 |
+
) -> Dict[str, Any]:
|
| 818 |
+
pass_count = sum(1 for r in runs if r.passed)
|
| 819 |
+
pass_rate = pass_count / max(len(runs), 1)
|
| 820 |
+
failure_keys = self._failure_keys(runs)
|
| 821 |
+
error_distribution = dict(Counter(failure_keys))
|
| 822 |
+
unique_failure_types = len(error_distribution)
|
| 823 |
+
entropy = failure_mode_entropy(runs)
|
| 824 |
+
|
| 825 |
+
summary = {
|
| 826 |
+
"env_type": env_type,
|
| 827 |
+
"should_train": should_train,
|
| 828 |
+
"reason": reason,
|
| 829 |
+
"stage": stage,
|
| 830 |
+
"runs": len(runs),
|
| 831 |
+
"passes": pass_count,
|
| 832 |
+
"pass_rate": round(pass_rate, 4),
|
| 833 |
+
"quick_runs": quick_runs,
|
| 834 |
+
"confirm_runs": confirm_runs,
|
| 835 |
+
"unique_failure_types": unique_failure_types,
|
| 836 |
+
"failure_entropy": entropy,
|
| 837 |
+
"error_distribution": error_distribution,
|
| 838 |
+
}
|
| 839 |
+
logger.info("[ENV] PREFLIGHT %s", summary)
|
| 840 |
+
return {
|
| 841 |
+
"runs": runs,
|
| 842 |
+
"pass_rate": pass_rate,
|
| 843 |
+
"failure_entropy": entropy,
|
| 844 |
+
"env_type": env_type,
|
| 845 |
+
"should_train": should_train,
|
| 846 |
+
"summary": summary,
|
| 847 |
+
}
|
| 848 |
+
|
| 849 |
+
_TIMING_RE = re.compile(r"\b\d+\.\d+s\b")
|
| 850 |
+
|
| 851 |
+
def _failure_keys(self, runs: List[RunRecord]) -> List[str]:
|
| 852 |
+
keys: List[str] = []
|
| 853 |
+
for r in runs:
|
| 854 |
+
if r.passed:
|
| 855 |
+
continue
|
| 856 |
+
error_type = r.error_type or "UnknownError"
|
| 857 |
+
message = (r.error_message or r.stderr_excerpt or "").strip()
|
| 858 |
+
msg_sig = message[:80] if message else ""
|
| 859 |
+
# Strip wall-clock durations ("0.17s", "1.23s") from the signature
|
| 860 |
+
# so that the same deterministic failure with slightly different
|
| 861 |
+
# timing doesn't inflate failure-entropy into a false "flaky" verdict.
|
| 862 |
+
msg_sig = self._TIMING_RE.sub("Xs", msg_sig)
|
| 863 |
+
keys.append(f"{error_type}:{msg_sig}")
|
| 864 |
+
return keys
|
| 865 |
+
|
| 866 |
+
def _is_infra_failure(self, run: RunRecord) -> bool:
|
| 867 |
+
if run.passed:
|
| 868 |
+
return False
|
| 869 |
+
error_type = run.error_type or ""
|
| 870 |
+
message = f"{run.error_message or ''}\n{run.stderr_excerpt or ''}"
|
| 871 |
+
if error_type in _INFRA_ERROR_TYPES:
|
| 872 |
+
return True
|
| 873 |
+
infra_needles = (
|
| 874 |
+
"ImportError",
|
| 875 |
+
"ModuleNotFoundError",
|
| 876 |
+
"SyntaxError",
|
| 877 |
+
"IndentationError",
|
| 878 |
+
"ERROR collecting",
|
| 879 |
+
"collected 0 items",
|
| 880 |
+
"fixture",
|
| 881 |
+
"pytest timed out",
|
| 882 |
+
)
|
| 883 |
+
return any(needle in message for needle in infra_needles)
|
| 884 |
+
|
| 885 |
+
def _run_tests(self, n: int) -> List[RunRecord]:
|
| 886 |
+
"""Run the target test n times, collecting results."""
|
| 887 |
+
if self.runner is None:
|
| 888 |
+
logger.warning("[ENV] No runner configured — returning synthetic runs")
|
| 889 |
+
return self._synthetic_runs(n)
|
| 890 |
+
|
| 891 |
+
try:
|
| 892 |
+
if hasattr(self.runner, "run_test_n_times"):
|
| 893 |
+
batch = self.runner.run_test_n_times(self.test_identifier, n)
|
| 894 |
+
if isinstance(batch, list) and batch:
|
| 895 |
+
return batch
|
| 896 |
+
except Exception as exc:
|
| 897 |
+
logger.debug("[ENV] run_test_n_times failed, falling back to loop: %s", exc)
|
| 898 |
+
|
| 899 |
+
results: List[RunRecord] = []
|
| 900 |
+
for _ in range(n):
|
| 901 |
+
try:
|
| 902 |
+
result = self.runner.run_test(self.test_identifier)
|
| 903 |
+
results.append(result)
|
| 904 |
+
except Exception as exc:
|
| 905 |
+
results.append(RunRecord(
|
| 906 |
+
passed=False,
|
| 907 |
+
duration_ms=0,
|
| 908 |
+
error_type=type(exc).__name__,
|
| 909 |
+
error_message=str(exc)[:200],
|
| 910 |
+
stderr_excerpt=None,
|
| 911 |
+
))
|
| 912 |
+
return results
|
| 913 |
+
|
| 914 |
+
def _synthetic_runs(self, n: int) -> List[RunRecord]:
|
| 915 |
+
"""Generate synthetic run results for development/testing."""
|
| 916 |
+
import random
|
| 917 |
+
from server.docker_runner import RunRecord
|
| 918 |
+
results = []
|
| 919 |
+
for _ in range(n):
|
| 920 |
+
passed = random.random() > 0.5
|
| 921 |
+
results.append(RunRecord(
|
| 922 |
+
passed=passed,
|
| 923 |
+
duration_ms=random.randint(10, 500),
|
| 924 |
+
error_type=None if passed else "TimeoutError",
|
| 925 |
+
error_message=None if passed else "Operation timed out",
|
| 926 |
+
stderr_excerpt=None,
|
| 927 |
+
))
|
| 928 |
+
return results
|
| 929 |
+
|
| 930 |
+
def _read_sources(self) -> Tuple[str, str]:
|
| 931 |
+
"""Read test and source-under-test files."""
|
| 932 |
+
test_source = ""
|
| 933 |
+
source_under_test = ""
|
| 934 |
+
|
| 935 |
+
# Find test file
|
| 936 |
+
test_parts = self.test_identifier.split("::")
|
| 937 |
+
test_file_hint = test_parts[0] if test_parts else ""
|
| 938 |
+
|
| 939 |
+
if test_file_hint:
|
| 940 |
+
test_path = self.repo_path / test_file_hint
|
| 941 |
+
if test_path.exists():
|
| 942 |
+
try:
|
| 943 |
+
test_source = test_path.read_text(encoding="utf-8", errors="ignore")[:8000]
|
| 944 |
+
except Exception:
|
| 945 |
+
pass
|
| 946 |
+
|
| 947 |
+
# Try to find source under test from imports
|
| 948 |
+
if test_source:
|
| 949 |
+
for candidate in self._source_candidates_from_test(test_source, test_file_hint):
|
| 950 |
+
if candidate.exists() and candidate.is_file():
|
| 951 |
+
try:
|
| 952 |
+
source_under_test = candidate.read_text(encoding="utf-8", errors="ignore")[:8000]
|
| 953 |
+
break
|
| 954 |
+
except Exception:
|
| 955 |
+
pass
|
| 956 |
+
|
| 957 |
+
return test_source, source_under_test
|
| 958 |
+
|
| 959 |
+
def _source_candidates_from_test(self, test_source: str, test_file_hint: str) -> List[Path]:
|
| 960 |
+
"""Resolve likely source files from imports in the target test."""
|
| 961 |
+
candidates: List[Path] = []
|
| 962 |
+
test_dir = (self.repo_path / test_file_hint).parent if test_file_hint else self.repo_path
|
| 963 |
+
|
| 964 |
+
def add_module_candidates(module: str) -> None:
|
| 965 |
+
if not module:
|
| 966 |
+
return
|
| 967 |
+
parts = module.replace(".", "/")
|
| 968 |
+
candidates.extend([
|
| 969 |
+
self.repo_path / f"{parts}.py",
|
| 970 |
+
self.repo_path / parts / "__init__.py",
|
| 971 |
+
self.repo_path / "src" / f"{parts}.py",
|
| 972 |
+
self.repo_path / "src" / parts / "__init__.py",
|
| 973 |
+
])
|
| 974 |
+
|
| 975 |
+
try:
|
| 976 |
+
tree = ast.parse(test_source)
|
| 977 |
+
for node in ast.walk(tree):
|
| 978 |
+
if isinstance(node, ast.Import):
|
| 979 |
+
for alias in node.names:
|
| 980 |
+
add_module_candidates(alias.name)
|
| 981 |
+
elif isinstance(node, ast.ImportFrom):
|
| 982 |
+
base_module = node.module or ""
|
| 983 |
+
if node.level:
|
| 984 |
+
base_dir = test_dir
|
| 985 |
+
for _ in range(max(node.level - 1, 0)):
|
| 986 |
+
base_dir = base_dir.parent
|
| 987 |
+
if base_module:
|
| 988 |
+
candidates.extend([
|
| 989 |
+
base_dir / f"{base_module.replace('.', '/')}.py",
|
| 990 |
+
base_dir / base_module.replace(".", "/") / "__init__.py",
|
| 991 |
+
])
|
| 992 |
+
for alias in node.names:
|
| 993 |
+
if alias.name != "*":
|
| 994 |
+
candidates.append(base_dir / f"{alias.name}.py")
|
| 995 |
+
else:
|
| 996 |
+
add_module_candidates(base_module)
|
| 997 |
+
for alias in node.names:
|
| 998 |
+
if alias.name != "*":
|
| 999 |
+
add_module_candidates(f"{base_module}.{alias.name}" if base_module else alias.name)
|
| 1000 |
+
except Exception:
|
| 1001 |
+
for imp in self._extract_imports(test_source):
|
| 1002 |
+
add_module_candidates(imp)
|
| 1003 |
+
|
| 1004 |
+
seen = set()
|
| 1005 |
+
unique_candidates: List[Path] = []
|
| 1006 |
+
for candidate in candidates:
|
| 1007 |
+
try:
|
| 1008 |
+
resolved = candidate.resolve()
|
| 1009 |
+
except Exception:
|
| 1010 |
+
resolved = candidate
|
| 1011 |
+
if resolved in seen:
|
| 1012 |
+
continue
|
| 1013 |
+
seen.add(resolved)
|
| 1014 |
+
unique_candidates.append(candidate)
|
| 1015 |
+
return unique_candidates
|
| 1016 |
+
|
| 1017 |
+
def _build_file_tree(self) -> List[str]:
|
| 1018 |
+
"""Build a compact file tree of the repo."""
|
| 1019 |
+
tree: List[str] = []
|
| 1020 |
+
skip = {"__pycache__", ".git", "node_modules", "venv", ".venv", ".pytest_cache", ".tox"}
|
| 1021 |
+
for root, dirs, files in os.walk(self.repo_path):
|
| 1022 |
+
dirs[:] = [d for d in dirs if d not in skip]
|
| 1023 |
+
rel = os.path.relpath(root, self.repo_path)
|
| 1024 |
+
depth = rel.count(os.sep)
|
| 1025 |
+
if depth > 3:
|
| 1026 |
+
continue
|
| 1027 |
+
for f in files:
|
| 1028 |
+
if f.endswith(".py"):
|
| 1029 |
+
tree.append(os.path.join(rel, f).replace("\\", "/"))
|
| 1030 |
+
return sorted(tree)[:50]
|
| 1031 |
+
|
| 1032 |
+
def _extract_imports(self, source: str) -> List[str]:
|
| 1033 |
+
"""Extract import statements from source."""
|
| 1034 |
+
imports = []
|
| 1035 |
+
try:
|
| 1036 |
+
tree = ast.parse(source)
|
| 1037 |
+
for node in ast.walk(tree):
|
| 1038 |
+
if isinstance(node, ast.Import):
|
| 1039 |
+
for alias in node.names:
|
| 1040 |
+
imports.append(alias.name)
|
| 1041 |
+
elif isinstance(node, ast.ImportFrom) and node.module:
|
| 1042 |
+
imports.append(node.module)
|
| 1043 |
+
except Exception:
|
| 1044 |
+
pass
|
| 1045 |
+
return imports
|
| 1046 |
+
|
| 1047 |
+
def _check_syntax(self, files: List[str]) -> Optional[str]:
|
| 1048 |
+
"""Check that modified files have valid Python syntax."""
|
| 1049 |
+
for f in files:
|
| 1050 |
+
path = self.repo_path / f
|
| 1051 |
+
if not path.exists() or path.suffix != ".py":
|
| 1052 |
+
continue
|
| 1053 |
+
try:
|
| 1054 |
+
source = path.read_text(encoding="utf-8", errors="ignore")
|
| 1055 |
+
ast.parse(source)
|
| 1056 |
+
except SyntaxError as exc:
|
| 1057 |
+
return f"{path.name}:{exc.lineno}: {exc.msg}"
|
| 1058 |
+
return None
|
| 1059 |
+
|
| 1060 |
+
def _check_order_dependency(self, baseline_rate: float) -> bool:
|
| 1061 |
+
"""Check for order dependency by running in reverse order."""
|
| 1062 |
+
if self.runner is None:
|
| 1063 |
+
return False
|
| 1064 |
+
try:
|
| 1065 |
+
if hasattr(self.runner, "run_reversed"):
|
| 1066 |
+
result = self.runner.run_reversed(self.test_identifier)
|
| 1067 |
+
reverse_rate = result.get("pass_rate", baseline_rate)
|
| 1068 |
+
return abs(reverse_rate - baseline_rate) > 0.15
|
| 1069 |
+
except Exception:
|
| 1070 |
+
pass
|
| 1071 |
+
return False
|
| 1072 |
+
|
| 1073 |
+
def _check_infrastructure_sensitivity(self, baseline_rate: float) -> bool:
|
| 1074 |
+
"""Check if the test is sensitive to infrastructure pressure."""
|
| 1075 |
+
if self.chaos_runner is None:
|
| 1076 |
+
return False
|
| 1077 |
+
try:
|
| 1078 |
+
result = self.chaos_runner.run_single(self.test_identifier)
|
| 1079 |
+
chaos_rate = 1.0 if result.get("passed", False) else 0.0
|
| 1080 |
+
return abs(chaos_rate - baseline_rate) > 0.2
|
| 1081 |
+
except Exception:
|
| 1082 |
+
pass
|
| 1083 |
+
return False
|
| 1084 |
+
|
| 1085 |
+
def _build_causal_graph(self, test_source: str) -> Tuple[Optional[Dict], List[str]]:
|
| 1086 |
+
"""Build causal graph for the test."""
|
| 1087 |
+
del test_source
|
| 1088 |
+
try:
|
| 1089 |
+
test_file, _, test_func = self.test_identifier.partition("::")
|
| 1090 |
+
entry_file = str(self.repo_path / test_file)
|
| 1091 |
+
entry_function = test_func or ""
|
| 1092 |
+
|
| 1093 |
+
if not entry_function:
|
| 1094 |
+
return None, []
|
| 1095 |
+
|
| 1096 |
+
builder = CrossRepoGraphBuilder(str(self.repo_path), max_depth=3)
|
| 1097 |
+
graph = builder.build(entry_file=entry_file, entry_function=entry_function)
|
| 1098 |
+
graph_dict = graph.to_observation_dict()
|
| 1099 |
+
hints = list(graph_dict.get("boundary_warnings", []))[:5]
|
| 1100 |
+
return graph_dict, hints
|
| 1101 |
+
except Exception as exc:
|
| 1102 |
+
logger.debug("[ENV] Causal graph construction failed: %s", exc)
|
| 1103 |
+
return None, []
|
| 1104 |
+
|
| 1105 |
+
def _resolve_default_target(self) -> str:
|
| 1106 |
+
"""Resolve the default target file for patches."""
|
| 1107 |
+
test_parts = self.test_identifier.split("::")
|
| 1108 |
+
return test_parts[0] if test_parts else ""
|
| 1109 |
+
|
| 1110 |
+
def _collect_sources(self) -> Dict[str, str]:
|
| 1111 |
+
"""Collect current on-disk source texts keyed by path relative to repo_path.
|
| 1112 |
+
|
| 1113 |
+
Used to feed the oracle engine with pre/post source snapshots.
|
| 1114 |
+
This is called *before* a patch is applied for pre-sources and *after*
|
| 1115 |
+
for post-sources, so it just reads the current disk state.
|
| 1116 |
+
"""
|
| 1117 |
+
sources: Dict[str, str] = {}
|
| 1118 |
+
if self._episode_state is None:
|
| 1119 |
+
return sources
|
| 1120 |
+
skip = {"__pycache__", ".git", "node_modules", "venv", ".venv", ".pytest_cache"}
|
| 1121 |
+
for root, dirs, files in os.walk(self.repo_path):
|
| 1122 |
+
dirs[:] = [d for d in dirs if d not in skip]
|
| 1123 |
+
for f in files:
|
| 1124 |
+
if not f.endswith(".py"):
|
| 1125 |
+
continue
|
| 1126 |
+
full = Path(root) / f
|
| 1127 |
+
rel = str(full.relative_to(self.repo_path)).replace("\\", "/")
|
| 1128 |
+
try:
|
| 1129 |
+
sources[rel] = full.read_text(encoding="utf-8", errors="ignore")
|
| 1130 |
+
except Exception:
|
| 1131 |
+
pass
|
| 1132 |
+
return sources
|
| 1133 |
+
|
| 1134 |
+
def _done_reason(self, pass_rate: float, done: bool) -> str:
|
| 1135 |
+
if not done:
|
| 1136 |
+
return "in_progress"
|
| 1137 |
+
if pass_rate >= 1.0:
|
| 1138 |
+
return "fully_stable"
|
| 1139 |
+
if self._episode_state and self._episode_state.regression_detected:
|
| 1140 |
+
return "regression_detected"
|
| 1141 |
+
if self._episode_state and self._episode_state.step_count >= self.max_steps:
|
| 1142 |
+
return "max_steps_reached"
|
| 1143 |
+
return "unknown"
|
| 1144 |
+
|
| 1145 |
+
def _reset_demo_repo_if_present(self) -> None:
|
| 1146 |
+
"""Restore bundled demo repos before each episode when they provide a reset script."""
|
| 1147 |
+
if os.environ.get("FF_SKIP_DEMO_RESET", "0") == "1":
|
| 1148 |
+
return
|
| 1149 |
+
reset_script = self.repo_path / "reset_demo.py"
|
| 1150 |
+
if not reset_script.exists():
|
| 1151 |
+
return
|
| 1152 |
+
try:
|
| 1153 |
+
runpy.run_path(str(reset_script), run_name="__main__")
|
| 1154 |
+
except Exception as exc:
|
| 1155 |
+
logger.warning("[ENV] Demo reset script failed: %s", exc)
|
| 1156 |
+
|
| 1157 |
+
@property
|
| 1158 |
+
def state(self) -> FlakeForgeState:
|
| 1159 |
+
if self._openenv_state is not None:
|
| 1160 |
+
return self._openenv_state
|
| 1161 |
+
if self._episode_state is not None:
|
| 1162 |
+
return FlakeForgeState(
|
| 1163 |
+
episode_id=self._episode_state.episode_id,
|
| 1164 |
+
step_count=self._episode_state.step_count,
|
| 1165 |
+
done=self._episode_state.done,
|
| 1166 |
+
current_pass_rate=self._episode_state.current_pass_rate,
|
| 1167 |
+
baseline_pass_rate=self._episode_state.baseline_pass_rate,
|
| 1168 |
+
regression_detected=self._episode_state.regression_detected,
|
| 1169 |
+
env_type=self._episode_state.env_type,
|
| 1170 |
+
should_train=self._episode_state.should_train,
|
| 1171 |
+
)
|
| 1172 |
+
return FlakeForgeState(
|
| 1173 |
+
episode_id="",
|
| 1174 |
+
step_count=0,
|
| 1175 |
+
done=False,
|
| 1176 |
+
current_pass_rate=0.0,
|
| 1177 |
+
baseline_pass_rate=0.0,
|
| 1178 |
+
regression_detected=False,
|
| 1179 |
+
env_type="unknown",
|
| 1180 |
+
should_train=True,
|
| 1181 |
+
)
|
| 1182 |
+
|
| 1183 |
+
|
| 1184 |
+
# ── Factory for OpenEnv ──────────────────────────────────────────────────────
|
| 1185 |
+
|
| 1186 |
+
def create_flakeforge_environment(
|
| 1187 |
+
repo_path: str,
|
| 1188 |
+
test_identifier: str,
|
| 1189 |
+
**kwargs: Any,
|
| 1190 |
+
) -> FlakeForgeEnvironment:
|
| 1191 |
+
"""Create a FlakeForge V3 environment instance."""
|
| 1192 |
+
return FlakeForgeEnvironment(
|
| 1193 |
+
repo_path=repo_path,
|
| 1194 |
+
test_identifier=test_identifier,
|
| 1195 |
+
**kwargs,
|
| 1196 |
+
)
|
server/__init__.py
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""FlakeForge V3 Server — environment, reward, and detection."""
|
| 2 |
+
|
| 3 |
+
from .FlakeForge_environment import FlakeForgeEnvironment, create_flakeforge_environment
|
| 4 |
+
from .reward import compute_verifiable_reward
|
| 5 |
+
from .patch_applier import apply_search_replace_patch, parse_search_replace_hunks
|
| 6 |
+
from .deep_flakiness import build_deep_observation_signals, extract_failure_frontier
|
| 7 |
+
from .state import EpisodeState
|
| 8 |
+
|
| 9 |
+
__all__ = [
|
| 10 |
+
"FlakeForgeEnvironment",
|
| 11 |
+
"create_flakeforge_environment",
|
| 12 |
+
"compute_verifiable_reward",
|
| 13 |
+
"apply_search_replace_patch",
|
| 14 |
+
"parse_search_replace_hunks",
|
| 15 |
+
"build_deep_observation_signals",
|
| 16 |
+
"extract_failure_frontier",
|
| 17 |
+
"EpisodeState",
|
| 18 |
+
]
|
server/app.py
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Copyright (c) Meta Platforms, Inc. and affiliates.
|
| 2 |
+
# All rights reserved.
|
| 3 |
+
#
|
| 4 |
+
# This source code is licensed under the BSD-style license found in the
|
| 5 |
+
# LICENSE file in the root directory of this source tree.
|
| 6 |
+
|
| 7 |
+
"""FastAPI application for the FlakeForge environment server."""
|
| 8 |
+
|
| 9 |
+
import sys
|
| 10 |
+
import os
|
| 11 |
+
from pathlib import Path
|
| 12 |
+
|
| 13 |
+
# Add project root to sys.path to resolve 'agent' and 'models' as top-level modules
|
| 14 |
+
project_root = str(Path(__file__).parents[1])
|
| 15 |
+
if project_root not in sys.path:
|
| 16 |
+
sys.path.insert(0, project_root)
|
| 17 |
+
|
| 18 |
+
try:
|
| 19 |
+
from openenv.core.env_server.http_server import create_app
|
| 20 |
+
except Exception as e: # pragma: no cover
|
| 21 |
+
raise ImportError(
|
| 22 |
+
"openenv is required for the web interface. Install dependencies with '\n uv sync\n'"
|
| 23 |
+
) from e
|
| 24 |
+
|
| 25 |
+
try:
|
| 26 |
+
from ..models import FlakeForgeAction, FlakeForgeObservation
|
| 27 |
+
from .FlakeForge_environment import FlakeForgeEnvironment
|
| 28 |
+
except ImportError:
|
| 29 |
+
try:
|
| 30 |
+
from FlakeForge.models import FlakeForgeAction, FlakeForgeObservation
|
| 31 |
+
from FlakeForge.server.FlakeForge_environment import FlakeForgeEnvironment
|
| 32 |
+
except ImportError:
|
| 33 |
+
from models import FlakeForgeAction, FlakeForgeObservation # type: ignore
|
| 34 |
+
from server.FlakeForge_environment import FlakeForgeEnvironment # type: ignore
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
from fastapi import FastAPI
|
| 38 |
+
from fastapi.responses import HTMLResponse
|
| 39 |
+
|
| 40 |
+
app = create_app(FlakeForgeEnvironment, FlakeForgeAction, FlakeForgeObservation)
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
@app.get("/", response_class=HTMLResponse)
|
| 47 |
+
def homepage():
|
| 48 |
+
html_path = Path(__file__).parents[1] / "templates" / "index.html"
|
| 49 |
+
return html_path.read_text(encoding="utf-8")
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
def main(host: str = "0.0.0.0", port: int = 8000):
|
| 53 |
+
"""
|
| 54 |
+
Entry point for direct execution via uv run or python -m.
|
| 55 |
+
|
| 56 |
+
This function enables running the server without Docker:
|
| 57 |
+
uv run --project . server
|
| 58 |
+
uv run --project . server --port 8001
|
| 59 |
+
python -m FlakeForge.server.app
|
| 60 |
+
|
| 61 |
+
Args:
|
| 62 |
+
host: Host address to bind to (default: "0.0.0.0")
|
| 63 |
+
port: Port number to listen on (default: 8000)
|
| 64 |
+
|
| 65 |
+
For production deployments, consider using uvicorn directly with
|
| 66 |
+
multiple workers:
|
| 67 |
+
uvicorn FlakeForge.server.app:app --workers 4
|
| 68 |
+
"""
|
| 69 |
+
import argparse
|
| 70 |
+
import uvicorn
|
| 71 |
+
|
| 72 |
+
parser = argparse.ArgumentParser(add_help=False)
|
| 73 |
+
parser.add_argument("--port", type=int, default=port)
|
| 74 |
+
args, _ = parser.parse_known_args()
|
| 75 |
+
|
| 76 |
+
uvicorn.run(app, host=host, port=args.port)
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
if __name__ == "__main__":
|
| 80 |
+
main()
|
server/causal_graph.py
ADDED
|
@@ -0,0 +1,527 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Cross-Repository Causal Graph Engine — traces call chains and detects boundaries.
|
| 2 |
+
"""
|
| 3 |
+
|
| 4 |
+
from __future__ import annotations
|
| 5 |
+
|
| 6 |
+
import ast
|
| 7 |
+
import logging
|
| 8 |
+
from dataclasses import dataclass, field
|
| 9 |
+
from pathlib import Path
|
| 10 |
+
from typing import Any, Dict, List, Optional, Set
|
| 11 |
+
|
| 12 |
+
logger = logging.getLogger(__name__)
|
| 13 |
+
|
| 14 |
+
# ── External boundary signatures ──────────────────────────────────────────────
|
| 15 |
+
_HTTP_SIGNATURES: Set[str] = {
|
| 16 |
+
"requests.get", "requests.post", "requests.put", "requests.delete", "requests.patch",
|
| 17 |
+
"httpx.get", "httpx.post", "httpx.AsyncClient", "aiohttp.ClientSession",
|
| 18 |
+
"urllib.request.urlopen", "urllib.request.urlretrieve",
|
| 19 |
+
}
|
| 20 |
+
_DB_SIGNATURES: Set[str] = {
|
| 21 |
+
"session.commit", "session.execute", "session.add", "session.flush",
|
| 22 |
+
"cursor.execute", "cursor.executemany",
|
| 23 |
+
"db.commit", "db.execute", "db.session.commit", "db.session.execute",
|
| 24 |
+
"engine.connect", "engine.execute",
|
| 25 |
+
"collection.find", "collection.insert_one", "collection.update_one",
|
| 26 |
+
"redis.set", "redis.get", "redis.hset",
|
| 27 |
+
}
|
| 28 |
+
_QUEUE_SIGNATURES: Set[str] = {
|
| 29 |
+
"producer.send", "channel.basic_publish", "queue.put", "queue.put_nowait",
|
| 30 |
+
"celery.send_task", "task.delay", "task.apply_async",
|
| 31 |
+
}
|
| 32 |
+
_GRPC_SIGNATURES: Set[str] = {"stub.", "channel.unary_unary", "channel.stream_unary"}
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
# ── Data classes ──────────────────────────────────────────────────────────────
|
| 36 |
+
|
| 37 |
+
@dataclass
|
| 38 |
+
class CausalNode:
|
| 39 |
+
node_id: str # e.g. "billing.charge"
|
| 40 |
+
module_path: str # dotted import path
|
| 41 |
+
source_file: str # absolute path to the file
|
| 42 |
+
source_excerpt: str # first 60 lines of the function body
|
| 43 |
+
is_external_boundary: bool = False
|
| 44 |
+
boundary_type: Optional[str] = None # "http" | "db" | "queue" | "grpc"
|
| 45 |
+
is_async: bool = False
|
| 46 |
+
depth: int = 0
|
| 47 |
+
warnings: List[str] = field(default_factory=list)
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
@dataclass
|
| 51 |
+
class CausalEdge:
|
| 52 |
+
caller_id: str
|
| 53 |
+
callee_id: str
|
| 54 |
+
call_site_line: int
|
| 55 |
+
call_type: str = "direct" # "direct" | "async_await" | "thread" | "subprocess"
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
@dataclass
|
| 59 |
+
class CausalGraph:
|
| 60 |
+
nodes: List[CausalNode]
|
| 61 |
+
edges: List[CausalEdge]
|
| 62 |
+
boundary_warnings: List[str]
|
| 63 |
+
entry_node_id: str
|
| 64 |
+
max_depth_reached: int
|
| 65 |
+
unresolved_imports: List[str] = field(default_factory=list) # chains the tracer couldn't follow
|
| 66 |
+
|
| 67 |
+
def to_observation_dict(self) -> Dict[str, Any]:
|
| 68 |
+
"""Render a compact, token-efficient summary for the LLM."""
|
| 69 |
+
return {
|
| 70 |
+
"entry": self.entry_node_id,
|
| 71 |
+
"max_depth": self.max_depth_reached,
|
| 72 |
+
"nodes": [
|
| 73 |
+
{
|
| 74 |
+
"id": n.node_id,
|
| 75 |
+
"async": n.is_async,
|
| 76 |
+
"depth": n.depth,
|
| 77 |
+
"boundary": n.boundary_type,
|
| 78 |
+
"file": Path(n.source_file).name,
|
| 79 |
+
"excerpt_lines": n.source_excerpt.count("\n") + 1,
|
| 80 |
+
"warnings": n.warnings,
|
| 81 |
+
}
|
| 82 |
+
for n in self.nodes
|
| 83 |
+
],
|
| 84 |
+
"edges": [
|
| 85 |
+
{
|
| 86 |
+
"from": e.caller_id,
|
| 87 |
+
"to": e.callee_id,
|
| 88 |
+
"line": e.call_site_line,
|
| 89 |
+
"type": e.call_type,
|
| 90 |
+
}
|
| 91 |
+
for e in self.edges
|
| 92 |
+
],
|
| 93 |
+
"boundary_warnings": self.boundary_warnings,
|
| 94 |
+
"boundary_nodes": [n.node_id for n in self.nodes if n.is_external_boundary],
|
| 95 |
+
# Tells the agent which import chains were cut off (can't be traced on disk)
|
| 96 |
+
"unresolved_imports": self.unresolved_imports,
|
| 97 |
+
}
|
| 98 |
+
|
| 99 |
+
|
| 100 |
+
@dataclass
|
| 101 |
+
class EpisodeCausalTrace:
|
| 102 |
+
"""Tracks symptoms, hypotheses and actions over one episode."""
|
| 103 |
+
|
| 104 |
+
symptoms: List[str] = field(default_factory=list)
|
| 105 |
+
hypotheses: List[Dict[str, Any]] = field(default_factory=list)
|
| 106 |
+
actions_taken: List[Dict[str, Any]] = field(default_factory=list)
|
| 107 |
+
final_cause: str = ""
|
| 108 |
+
fix_applied: str = ""
|
| 109 |
+
outcome: str = "failure"
|
| 110 |
+
|
| 111 |
+
def add_symptom(self, symptom: str) -> None:
|
| 112 |
+
if symptom and symptom not in self.symptoms:
|
| 113 |
+
self.symptoms.append(symptom)
|
| 114 |
+
|
| 115 |
+
def add_hypothesis(self, step: int, hypothesis: Dict[str, Any]) -> None:
|
| 116 |
+
payload = {"step": step, **hypothesis}
|
| 117 |
+
self.hypotheses.append(payload)
|
| 118 |
+
|
| 119 |
+
def add_action(self, step: int, action: Dict[str, Any]) -> None:
|
| 120 |
+
payload = {"step": step, **action}
|
| 121 |
+
self.actions_taken.append(payload)
|
| 122 |
+
|
| 123 |
+
def finalize(self, final_cause: str, fix_applied: str, success: bool) -> None:
|
| 124 |
+
self.final_cause = final_cause
|
| 125 |
+
self.fix_applied = fix_applied
|
| 126 |
+
self.outcome = "success" if success else "failure"
|
| 127 |
+
|
| 128 |
+
def to_dict(self) -> Dict[str, Any]:
|
| 129 |
+
return {
|
| 130 |
+
"symptoms": list(self.symptoms),
|
| 131 |
+
"hypotheses": list(self.hypotheses),
|
| 132 |
+
"actions_taken": list(self.actions_taken),
|
| 133 |
+
"final_cause": self.final_cause,
|
| 134 |
+
"fix_applied": self.fix_applied,
|
| 135 |
+
"outcome": self.outcome,
|
| 136 |
+
}
|
| 137 |
+
|
| 138 |
+
|
| 139 |
+
# ── Main builder class ─────────────────────────────────────────────────────────
|
| 140 |
+
|
| 141 |
+
class CrossRepoGraphBuilder:
|
| 142 |
+
"""
|
| 143 |
+
Builds a CausalGraph by walking the AST of every reachable function
|
| 144 |
+
starting from an entry point, following imports and call sites up to
|
| 145 |
+
``max_depth`` hops.
|
| 146 |
+
"""
|
| 147 |
+
|
| 148 |
+
# Common alternative source layout roots to search when resolving imports.
|
| 149 |
+
# Covers: flat layout, src-layout, app-layout (FastAPI/Django conventions).
|
| 150 |
+
_LAYOUT_ROOTS = [".", "src", "app", "lib"]
|
| 151 |
+
|
| 152 |
+
def __init__(self, repo_root: str, max_depth: int = 3) -> None:
|
| 153 |
+
self.repo_root = Path(repo_root)
|
| 154 |
+
self.max_depth = max_depth
|
| 155 |
+
self._visited: Set[str] = set()
|
| 156 |
+
self._nodes: List[CausalNode] = []
|
| 157 |
+
self._edges: List[CausalEdge] = []
|
| 158 |
+
self._boundary_warnings: List[str] = []
|
| 159 |
+
self._unresolved_imports: Set[str] = set()
|
| 160 |
+
|
| 161 |
+
# ── Public API ─────────────────────────────────────────────────────────────
|
| 162 |
+
|
| 163 |
+
def build(self, entry_file: str, entry_function: str) -> CausalGraph:
|
| 164 |
+
"""Build and return the full causal graph from the entry point."""
|
| 165 |
+
self._visited.clear()
|
| 166 |
+
self._nodes.clear()
|
| 167 |
+
self._edges.clear()
|
| 168 |
+
self._boundary_warnings.clear()
|
| 169 |
+
|
| 170 |
+
entry_id = f"{Path(entry_file).stem}.{entry_function}"
|
| 171 |
+
self._walk(
|
| 172 |
+
file_path=entry_file,
|
| 173 |
+
function_name=entry_function,
|
| 174 |
+
node_id=entry_id,
|
| 175 |
+
depth=0,
|
| 176 |
+
parent_id=None,
|
| 177 |
+
call_site_line=0,
|
| 178 |
+
call_type="direct",
|
| 179 |
+
)
|
| 180 |
+
|
| 181 |
+
return CausalGraph(
|
| 182 |
+
nodes=self._nodes,
|
| 183 |
+
edges=self._edges,
|
| 184 |
+
boundary_warnings=self._boundary_warnings,
|
| 185 |
+
entry_node_id=entry_id,
|
| 186 |
+
max_depth_reached=max((n.depth for n in self._nodes), default=0),
|
| 187 |
+
unresolved_imports=list(self._unresolved_imports),
|
| 188 |
+
)
|
| 189 |
+
|
| 190 |
+
# ── Internal helpers ───────────────────────────────────────────────────────
|
| 191 |
+
|
| 192 |
+
def _walk(
|
| 193 |
+
self,
|
| 194 |
+
file_path: str,
|
| 195 |
+
function_name: str,
|
| 196 |
+
node_id: str,
|
| 197 |
+
depth: int,
|
| 198 |
+
parent_id: Optional[str],
|
| 199 |
+
call_site_line: int,
|
| 200 |
+
call_type: str,
|
| 201 |
+
) -> None:
|
| 202 |
+
if node_id in self._visited or depth > self.max_depth:
|
| 203 |
+
return
|
| 204 |
+
self._visited.add(node_id)
|
| 205 |
+
|
| 206 |
+
src_path = Path(file_path)
|
| 207 |
+
if not src_path.exists():
|
| 208 |
+
return
|
| 209 |
+
|
| 210 |
+
try:
|
| 211 |
+
source = src_path.read_text(encoding="utf-8", errors="replace")
|
| 212 |
+
tree = ast.parse(source)
|
| 213 |
+
except Exception as exc:
|
| 214 |
+
logger.debug("causal_graph: failed to parse %s: %s", file_path, exc)
|
| 215 |
+
return
|
| 216 |
+
|
| 217 |
+
func_node = self._find_function(tree, function_name)
|
| 218 |
+
if func_node is None:
|
| 219 |
+
return
|
| 220 |
+
|
| 221 |
+
excerpt = self._extract_excerpt(source, func_node)
|
| 222 |
+
is_async = isinstance(func_node, ast.AsyncFunctionDef)
|
| 223 |
+
boundary_type, boundary_warnings = self._detect_boundaries(func_node, node_id, is_async)
|
| 224 |
+
|
| 225 |
+
node = CausalNode(
|
| 226 |
+
node_id=node_id,
|
| 227 |
+
module_path=f"{src_path.stem}.{function_name}",
|
| 228 |
+
source_file=file_path,
|
| 229 |
+
source_excerpt=excerpt,
|
| 230 |
+
is_external_boundary=boundary_type is not None,
|
| 231 |
+
boundary_type=boundary_type,
|
| 232 |
+
is_async=is_async,
|
| 233 |
+
depth=depth,
|
| 234 |
+
warnings=boundary_warnings,
|
| 235 |
+
)
|
| 236 |
+
self._nodes.append(node)
|
| 237 |
+
self._boundary_warnings.extend(boundary_warnings)
|
| 238 |
+
|
| 239 |
+
if parent_id is not None:
|
| 240 |
+
self._edges.append(CausalEdge(
|
| 241 |
+
caller_id=parent_id,
|
| 242 |
+
callee_id=node_id,
|
| 243 |
+
call_site_line=call_site_line,
|
| 244 |
+
call_type=call_type,
|
| 245 |
+
))
|
| 246 |
+
|
| 247 |
+
if depth < self.max_depth:
|
| 248 |
+
self._follow_calls(func_node, file_path, node_id, depth, tree)
|
| 249 |
+
|
| 250 |
+
@staticmethod
|
| 251 |
+
def _build_parent_map(
|
| 252 |
+
func_node: "ast.FunctionDef | ast.AsyncFunctionDef",
|
| 253 |
+
) -> Dict[int, ast.AST]:
|
| 254 |
+
"""
|
| 255 |
+
Build a {id(child): parent} mapping for every node inside func_node.
|
| 256 |
+
|
| 257 |
+
ast.walk() is a flat BFS iterator with no parent information.
|
| 258 |
+
The only reliable way to know a node's parent is a dedicated pre-pass.
|
| 259 |
+
This is the standard pattern used by astroid, pyflakes, and mypy.
|
| 260 |
+
"""
|
| 261 |
+
parent_map: Dict[int, ast.AST] = {}
|
| 262 |
+
for parent in ast.walk(func_node):
|
| 263 |
+
for child in ast.iter_child_nodes(parent):
|
| 264 |
+
parent_map[id(child)] = parent
|
| 265 |
+
return parent_map
|
| 266 |
+
|
| 267 |
+
def _follow_calls(
|
| 268 |
+
self,
|
| 269 |
+
func_node: "ast.FunctionDef | ast.AsyncFunctionDef",
|
| 270 |
+
current_file: str,
|
| 271 |
+
current_id: str,
|
| 272 |
+
depth: int,
|
| 273 |
+
tree: ast.Module,
|
| 274 |
+
) -> None:
|
| 275 |
+
"""Walk the body of a function to find outgoing calls and follow them."""
|
| 276 |
+
import_map = self._build_import_map(tree, current_file)
|
| 277 |
+
|
| 278 |
+
# Pre-pass: build parent map so we can correctly classify each call's context.
|
| 279 |
+
# Without this, ast.walk() gives no parent info and call_type is always 'direct'.
|
| 280 |
+
parent_map = self._build_parent_map(func_node)
|
| 281 |
+
|
| 282 |
+
for node in ast.walk(func_node):
|
| 283 |
+
if not isinstance(node, ast.Call):
|
| 284 |
+
continue
|
| 285 |
+
|
| 286 |
+
call_name = self._call_name(node)
|
| 287 |
+
if not call_name:
|
| 288 |
+
continue
|
| 289 |
+
|
| 290 |
+
# Correctly determine call type using the parent map.
|
| 291 |
+
call_type = "direct"
|
| 292 |
+
parent = parent_map.get(id(node))
|
| 293 |
+
if isinstance(parent, ast.Await):
|
| 294 |
+
call_type = "async_await"
|
| 295 |
+
elif isinstance(parent, ast.Call):
|
| 296 |
+
parent_name = self._call_name(parent) or ""
|
| 297 |
+
if parent_name in {
|
| 298 |
+
"threading.Thread",
|
| 299 |
+
"concurrent.futures.ThreadPoolExecutor",
|
| 300 |
+
"asyncio.to_thread",
|
| 301 |
+
"loop.run_in_executor",
|
| 302 |
+
}:
|
| 303 |
+
call_type = "thread"
|
| 304 |
+
|
| 305 |
+
# Resolve the file from import map
|
| 306 |
+
target_file = import_map.get(call_name.split(".")[0])
|
| 307 |
+
if target_file is None:
|
| 308 |
+
continue
|
| 309 |
+
|
| 310 |
+
callee_func = call_name.split(".")[-1]
|
| 311 |
+
callee_id = f"{Path(target_file).stem}.{callee_func}"
|
| 312 |
+
call_site_line = getattr(node, "lineno", 0)
|
| 313 |
+
|
| 314 |
+
self._walk(
|
| 315 |
+
file_path=target_file,
|
| 316 |
+
function_name=callee_func,
|
| 317 |
+
node_id=callee_id,
|
| 318 |
+
depth=depth + 1,
|
| 319 |
+
parent_id=current_id,
|
| 320 |
+
call_site_line=call_site_line,
|
| 321 |
+
call_type=call_type,
|
| 322 |
+
)
|
| 323 |
+
|
| 324 |
+
def _resolve_module_path(self, module_dotted: str) -> Optional[Path]:
|
| 325 |
+
"""
|
| 326 |
+
Resolve a dotted module name to a physical file path.
|
| 327 |
+
|
| 328 |
+
Searches multiple layout roots so we handle:
|
| 329 |
+
- Flat layout: repo_root/billing.py
|
| 330 |
+
- src-layout: repo_root/src/mypackage/billing.py
|
| 331 |
+
- app-layout: repo_root/app/billing.py
|
| 332 |
+
- Namespace pkg: repo_root/billing/ (no __init__.py, Python 3.3+)
|
| 333 |
+
|
| 334 |
+
If a module resolves to an __init__.py, we also scan its body for
|
| 335 |
+
re-exported names (``from .billing import charge``) so the tracer
|
| 336 |
+
can follow through package facades.
|
| 337 |
+
"""
|
| 338 |
+
module_rel = Path(module_dotted.replace(".", "/"))
|
| 339 |
+
for layout_root in self._LAYOUT_ROOTS:
|
| 340 |
+
base = self.repo_root / layout_root
|
| 341 |
+
candidates = [
|
| 342 |
+
base / f"{module_rel}.py",
|
| 343 |
+
base / module_rel / "__init__.py",
|
| 344 |
+
# Namespace packages — directory with no __init__.py
|
| 345 |
+
base / module_rel,
|
| 346 |
+
]
|
| 347 |
+
for candidate in candidates:
|
| 348 |
+
if candidate.is_file():
|
| 349 |
+
return candidate
|
| 350 |
+
if candidate.is_dir():
|
| 351 |
+
# Treat directory as namespace package — return None
|
| 352 |
+
# (caller will handle individual name resolution)
|
| 353 |
+
return None
|
| 354 |
+
return None
|
| 355 |
+
|
| 356 |
+
def _resolve_reexported_name(
|
| 357 |
+
self, init_path: Path, name: str
|
| 358 |
+
) -> Optional[Path]:
|
| 359 |
+
"""
|
| 360 |
+
If `init_path` is an __init__.py that re-exports `name` via
|
| 361 |
+
``from .submodule import name``, resolve to the submodule's file.
|
| 362 |
+
"""
|
| 363 |
+
try:
|
| 364 |
+
source = init_path.read_text(encoding="utf-8", errors="replace")
|
| 365 |
+
tree = ast.parse(source)
|
| 366 |
+
except Exception:
|
| 367 |
+
return None
|
| 368 |
+
|
| 369 |
+
for node in ast.walk(tree):
|
| 370 |
+
if not isinstance(node, ast.ImportFrom):
|
| 371 |
+
continue
|
| 372 |
+
if node.level == 0: # absolute import — not a re-export
|
| 373 |
+
continue
|
| 374 |
+
for alias in (node.names or []):
|
| 375 |
+
exported_name = alias.asname or alias.name
|
| 376 |
+
if exported_name == name and node.module:
|
| 377 |
+
# Resolve the relative sub-module
|
| 378 |
+
sub_rel = node.module.replace(".", "/")
|
| 379 |
+
sub_candidate = init_path.parent / f"{sub_rel}.py"
|
| 380 |
+
if sub_candidate.exists():
|
| 381 |
+
return sub_candidate
|
| 382 |
+
return None
|
| 383 |
+
|
| 384 |
+
def _build_import_map(self, tree: ast.Module, current_file: str) -> Dict[str, str]:
|
| 385 |
+
"""
|
| 386 |
+
Maps every imported name to its resolved file path within the repo.
|
| 387 |
+
|
| 388 |
+
Handles:
|
| 389 |
+
* ``from package import name`` (including re-exports via __init__.py)
|
| 390 |
+
* ``import module`` / ``import module as alias``
|
| 391 |
+
* src-layout, app-layout, flat layout
|
| 392 |
+
* Namespace packages
|
| 393 |
+
|
| 394 |
+
Names that cannot be resolved are logged to ``_unresolved_imports``
|
| 395 |
+
so the observation dict tells the agent where the trace was cut off.
|
| 396 |
+
"""
|
| 397 |
+
mapping: Dict[str, str] = {}
|
| 398 |
+
|
| 399 |
+
for node in ast.walk(tree):
|
| 400 |
+
if isinstance(node, ast.ImportFrom) and node.module:
|
| 401 |
+
resolved = self._resolve_module_path(node.module)
|
| 402 |
+
for alias in (node.names or []):
|
| 403 |
+
exported = alias.asname or alias.name
|
| 404 |
+
if resolved is not None and resolved.suffix == ".py":
|
| 405 |
+
# Direct module file resolved
|
| 406 |
+
mapping[exported] = str(resolved)
|
| 407 |
+
elif resolved is not None and resolved.name == "__init__.py":
|
| 408 |
+
# Package __init__.py — check for re-exports
|
| 409 |
+
reexported = self._resolve_reexported_name(resolved, alias.name)
|
| 410 |
+
mapping[exported] = str(reexported if reexported else resolved)
|
| 411 |
+
else:
|
| 412 |
+
# Could not resolve — record for the agent's observation
|
| 413 |
+
self._unresolved_imports.add(f"{node.module}.{alias.name}")
|
| 414 |
+
logger.debug(
|
| 415 |
+
"causal_graph: unresolved import '%s.%s' — "
|
| 416 |
+
"chain truncated here.",
|
| 417 |
+
node.module,
|
| 418 |
+
alias.name,
|
| 419 |
+
)
|
| 420 |
+
|
| 421 |
+
elif isinstance(node, ast.Import):
|
| 422 |
+
for alias in node.names:
|
| 423 |
+
resolved = self._resolve_module_path(alias.name)
|
| 424 |
+
imported_as = alias.asname or alias.name.split(".")[0]
|
| 425 |
+
if resolved is not None:
|
| 426 |
+
mapping[imported_as] = str(resolved)
|
| 427 |
+
else:
|
| 428 |
+
self._unresolved_imports.add(alias.name)
|
| 429 |
+
logger.debug(
|
| 430 |
+
"causal_graph: unresolved import '%s' — "
|
| 431 |
+
"chain truncated here.",
|
| 432 |
+
alias.name,
|
| 433 |
+
)
|
| 434 |
+
|
| 435 |
+
return mapping
|
| 436 |
+
|
| 437 |
+
@staticmethod
|
| 438 |
+
def _find_function(
|
| 439 |
+
tree: ast.Module, name: str
|
| 440 |
+
) -> Optional[ast.FunctionDef | ast.AsyncFunctionDef]:
|
| 441 |
+
for node in ast.walk(tree):
|
| 442 |
+
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) and node.name == name:
|
| 443 |
+
return node
|
| 444 |
+
return None
|
| 445 |
+
|
| 446 |
+
@staticmethod
|
| 447 |
+
def _extract_excerpt(
|
| 448 |
+
source: str,
|
| 449 |
+
func_node: "ast.FunctionDef | ast.AsyncFunctionDef",
|
| 450 |
+
max_lines: int = 60,
|
| 451 |
+
) -> str:
|
| 452 |
+
lines = source.splitlines()
|
| 453 |
+
start = func_node.lineno - 1
|
| 454 |
+
end = min(start + max_lines, len(lines))
|
| 455 |
+
return "\n".join(lines[start:end])
|
| 456 |
+
|
| 457 |
+
@staticmethod
|
| 458 |
+
def _call_name(call_node: ast.Call) -> Optional[str]:
|
| 459 |
+
"""Reconstruct a dotted call name from an AST Call node."""
|
| 460 |
+
func = call_node.func
|
| 461 |
+
if isinstance(func, ast.Name):
|
| 462 |
+
return func.id
|
| 463 |
+
if isinstance(func, ast.Attribute):
|
| 464 |
+
parts = []
|
| 465 |
+
current: ast.expr = func
|
| 466 |
+
while isinstance(current, ast.Attribute):
|
| 467 |
+
parts.append(current.attr)
|
| 468 |
+
current = current.value
|
| 469 |
+
if isinstance(current, ast.Name):
|
| 470 |
+
parts.append(current.id)
|
| 471 |
+
return ".".join(reversed(parts))
|
| 472 |
+
return None
|
| 473 |
+
|
| 474 |
+
def _detect_boundaries(
|
| 475 |
+
self,
|
| 476 |
+
func_node: "ast.FunctionDef | ast.AsyncFunctionDef",
|
| 477 |
+
node_id: str,
|
| 478 |
+
is_async: bool,
|
| 479 |
+
) -> tuple[Optional[str], List[str]]:
|
| 480 |
+
"""
|
| 481 |
+
Detects external boundary calls and emits warnings for dangerous patterns
|
| 482 |
+
(e.g. blocking call inside async function, thread lock in async context).
|
| 483 |
+
"""
|
| 484 |
+
warnings: List[str] = []
|
| 485 |
+
found_boundary: Optional[str] = None
|
| 486 |
+
has_threading_lock = False
|
| 487 |
+
has_blocking_call = False
|
| 488 |
+
|
| 489 |
+
for node in ast.walk(func_node):
|
| 490 |
+
if not isinstance(node, ast.Call):
|
| 491 |
+
continue
|
| 492 |
+
call_name = self._call_name(node) or ""
|
| 493 |
+
|
| 494 |
+
# Detect boundary types
|
| 495 |
+
if any(sig in call_name for sig in _HTTP_SIGNATURES):
|
| 496 |
+
found_boundary = "http"
|
| 497 |
+
elif any(sig in call_name for sig in _DB_SIGNATURES):
|
| 498 |
+
found_boundary = "db"
|
| 499 |
+
elif any(sig in call_name for sig in _QUEUE_SIGNATURES):
|
| 500 |
+
found_boundary = "queue"
|
| 501 |
+
elif any(sig.split(".")[0] in call_name for sig in _GRPC_SIGNATURES):
|
| 502 |
+
found_boundary = "grpc"
|
| 503 |
+
|
| 504 |
+
# Detect dangerous patterns
|
| 505 |
+
if "threading.Lock" in call_name or "threading.RLock" in call_name:
|
| 506 |
+
has_threading_lock = True
|
| 507 |
+
if call_name in {"time.sleep", "open", "socket.recv", "socket.accept"}:
|
| 508 |
+
has_blocking_call = True
|
| 509 |
+
|
| 510 |
+
# Emit warnings for dangerous combinations
|
| 511 |
+
if is_async and has_threading_lock:
|
| 512 |
+
msg = (
|
| 513 |
+
f"[{node_id}] threading.Lock() used inside async function — "
|
| 514 |
+
"blocks the event loop. Use asyncio.Lock() instead. "
|
| 515 |
+
"Likely cause: ASYNC_DEADLOCK"
|
| 516 |
+
)
|
| 517 |
+
warnings.append(msg)
|
| 518 |
+
|
| 519 |
+
if is_async and has_blocking_call:
|
| 520 |
+
msg = (
|
| 521 |
+
f"[{node_id}] blocking I/O call detected inside async function — "
|
| 522 |
+
"offload with loop.run_in_executor() or use async alternative. "
|
| 523 |
+
"Likely cause: ASYNC_DEADLOCK / TIMING_RACE"
|
| 524 |
+
)
|
| 525 |
+
warnings.append(msg)
|
| 526 |
+
|
| 527 |
+
return found_boundary, warnings
|
server/chaos_runner.py
ADDED
|
@@ -0,0 +1,226 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Copyright (c) Meta Platforms, Inc. and affiliates.
|
| 2 |
+
# All rights reserved.
|
| 3 |
+
#
|
| 4 |
+
# This source code is licensed under the BSD-style license found in the
|
| 5 |
+
# LICENSE file in the root directory of this source tree.
|
| 6 |
+
|
| 7 |
+
"""
|
| 8 |
+
Pillar 2 — Chaos-Amplified RL Environment.
|
| 9 |
+
|
| 10 |
+
Runs pytest suites under controlled resource stress (CPU, memory, network
|
| 11 |
+
latency) so that infrastructure-sensitive race conditions reliably surface
|
| 12 |
+
in the sandbox — not just in production.
|
| 13 |
+
|
| 14 |
+
Design rationale:
|
| 15 |
+
Deep codebase flakiness (race conditions under CPU load, GC-pause timeouts,
|
| 16 |
+
thundering-herd retries under network latency) only manifests when the host
|
| 17 |
+
is stressed. An RL agent cannot learn to fix what it cannot reproduce.
|
| 18 |
+
ChaosAmplifiedRunner injects stress-ng / tc netem before each test batch
|
| 19 |
+
and annotates RunRecords with the active chaos profile.
|
| 20 |
+
"""
|
| 21 |
+
|
| 22 |
+
from __future__ import annotations
|
| 23 |
+
|
| 24 |
+
import logging
|
| 25 |
+
import os
|
| 26 |
+
import signal
|
| 27 |
+
import subprocess
|
| 28 |
+
import time
|
| 29 |
+
from enum import Enum
|
| 30 |
+
from typing import List, Optional
|
| 31 |
+
|
| 32 |
+
try:
|
| 33 |
+
from ..models import RunRecord
|
| 34 |
+
from .docker_runner import DockerTestRunner
|
| 35 |
+
except ImportError:
|
| 36 |
+
try:
|
| 37 |
+
from FlakeForge.models import RunRecord # type: ignore
|
| 38 |
+
from FlakeForge.server.docker_runner import DockerTestRunner # type: ignore
|
| 39 |
+
except ImportError:
|
| 40 |
+
from models import RunRecord # type: ignore
|
| 41 |
+
from server.docker_runner import DockerTestRunner # type: ignore
|
| 42 |
+
|
| 43 |
+
logger = logging.getLogger(__name__)
|
| 44 |
+
|
| 45 |
+
# ── Chaos profiles ─────────────────────────────────────────────────────────────
|
| 46 |
+
|
| 47 |
+
class ChaosProfile(str, Enum):
|
| 48 |
+
NONE = "none" # V1 baseline — no stressor
|
| 49 |
+
CPU = "cpu" # 2 stress-ng workers at 100% CPU
|
| 50 |
+
MEM = "mem" # Consume 70% of available container RAM
|
| 51 |
+
NET = "net" # 50ms ± 20ms network latency via tc netem
|
| 52 |
+
COMPOUND = "compound" # All three simultaneously (production nightmare mode)
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
# ── Stressor command templates ──────────────────────────────────────────────────
|
| 56 |
+
|
| 57 |
+
_STRESSOR_CMDS: dict[ChaosProfile, list[list[str]]] = {
|
| 58 |
+
ChaosProfile.CPU: [
|
| 59 |
+
["stress-ng", "--cpu", "2", "--timeout", "120s", "--metrics-brief"],
|
| 60 |
+
],
|
| 61 |
+
ChaosProfile.MEM: [
|
| 62 |
+
["stress-ng", "--vm", "1", "--vm-bytes", "70%", "--timeout", "120s", "--metrics-brief"],
|
| 63 |
+
],
|
| 64 |
+
ChaosProfile.NET: [
|
| 65 |
+
# Add latency — requires iproute2 (tc) to be installed and NET_ADMIN capability
|
| 66 |
+
["tc", "qdisc", "add", "dev", "eth0", "root", "netem",
|
| 67 |
+
"delay", "50ms", "20ms", "distribution", "normal"],
|
| 68 |
+
],
|
| 69 |
+
ChaosProfile.COMPOUND: [
|
| 70 |
+
["stress-ng", "--cpu", "2", "--vm", "1", "--vm-bytes", "60%", "--timeout", "120s"],
|
| 71 |
+
["tc", "qdisc", "add", "dev", "eth0", "root", "netem",
|
| 72 |
+
"delay", "50ms", "20ms", "distribution", "normal"],
|
| 73 |
+
],
|
| 74 |
+
}
|
| 75 |
+
|
| 76 |
+
_NET_TEARDOWN_CMD = ["tc", "qdisc", "del", "dev", "eth0", "root"]
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
# ── Main class ──────────────────────────────────────────────────────────────────
|
| 80 |
+
|
| 81 |
+
class ChaosAmplifiedRunner(DockerTestRunner):
|
| 82 |
+
"""
|
| 83 |
+
Extends DockerTestRunner with the ability to inject stress before each batch.
|
| 84 |
+
|
| 85 |
+
Usage::
|
| 86 |
+
|
| 87 |
+
runner = ChaosAmplifiedRunner("/app/seed_repos/cpu_timing_race")
|
| 88 |
+
records = runner.run_test_n_times_chaos(
|
| 89 |
+
test_id="tests/test_flaky.py::test_flaky_case",
|
| 90 |
+
n=20,
|
| 91 |
+
profile=ChaosProfile.CPU,
|
| 92 |
+
)
|
| 93 |
+
"""
|
| 94 |
+
|
| 95 |
+
STRESSOR_WARMUP_SECONDS: float = 2.0 # time to let stressor stabilise before tests
|
| 96 |
+
STRESSOR_TEARDOWN_WAIT: float = 1.0 # time to let OS recover after stressor exits
|
| 97 |
+
|
| 98 |
+
def run_test_n_times_chaos(
|
| 99 |
+
self,
|
| 100 |
+
test_id: str,
|
| 101 |
+
n: int,
|
| 102 |
+
profile: ChaosProfile,
|
| 103 |
+
max_workers: int = 4,
|
| 104 |
+
) -> List[RunRecord]:
|
| 105 |
+
"""
|
| 106 |
+
Run `test_id` `n` times while a chaos stressor is active.
|
| 107 |
+
|
| 108 |
+
Falls back to the normal (no-stressor) path when:
|
| 109 |
+
- profile is NONE
|
| 110 |
+
- the stressor binary is not available (graceful degradation)
|
| 111 |
+
"""
|
| 112 |
+
if profile == ChaosProfile.NONE:
|
| 113 |
+
return self.run_test_n_times(test_id, n=n, max_workers=max_workers)
|
| 114 |
+
|
| 115 |
+
stressor_procs = self._start_stressor(profile)
|
| 116 |
+
if not stressor_procs:
|
| 117 |
+
# Binaries unavailable — degrade gracefully and log warning
|
| 118 |
+
logger.warning(
|
| 119 |
+
"chaos_runner: stressor unavailable for profile=%s — "
|
| 120 |
+
"falling back to clean run. Install stress-ng and iproute2 "
|
| 121 |
+
"inside the Docker image to enable chaos amplification.",
|
| 122 |
+
profile.value,
|
| 123 |
+
)
|
| 124 |
+
return self.run_test_n_times(test_id, n=n, max_workers=max_workers)
|
| 125 |
+
|
| 126 |
+
try:
|
| 127 |
+
time.sleep(self.STRESSOR_WARMUP_SECONDS)
|
| 128 |
+
records = self.run_test_n_times(test_id, n=n, max_workers=max_workers)
|
| 129 |
+
finally:
|
| 130 |
+
self._stop_stressors(stressor_procs, profile)
|
| 131 |
+
time.sleep(self.STRESSOR_TEARDOWN_WAIT)
|
| 132 |
+
|
| 133 |
+
return records
|
| 134 |
+
|
| 135 |
+
def is_infrastructure_sensitive(
|
| 136 |
+
self,
|
| 137 |
+
test_id: str,
|
| 138 |
+
clean_pass_rate: float,
|
| 139 |
+
profile: ChaosProfile = ChaosProfile.CPU,
|
| 140 |
+
n: int = 10,
|
| 141 |
+
sensitivity_threshold: float = 0.2,
|
| 142 |
+
) -> tuple[bool, float]:
|
| 143 |
+
"""
|
| 144 |
+
Check whether a flake worsens significantly under chaos.
|
| 145 |
+
|
| 146 |
+
Returns (is_sensitive: bool, chaos_pass_rate: float).
|
| 147 |
+
Sensitive means: chaos_pass_rate < clean_pass_rate - threshold.
|
| 148 |
+
"""
|
| 149 |
+
chaos_records = self.run_test_n_times_chaos(test_id, n=n, profile=profile)
|
| 150 |
+
passed = sum(1 for r in chaos_records if r.passed)
|
| 151 |
+
chaos_pass_rate = passed / len(chaos_records) if chaos_records else 0.0
|
| 152 |
+
is_sensitive = chaos_pass_rate < (clean_pass_rate - sensitivity_threshold)
|
| 153 |
+
return is_sensitive, chaos_pass_rate
|
| 154 |
+
|
| 155 |
+
# ── Internal stressor management ────────────────────────────────────────────
|
| 156 |
+
|
| 157 |
+
def _start_stressor(self, profile: ChaosProfile) -> List[subprocess.Popen]:
|
| 158 |
+
"""
|
| 159 |
+
Launches the stressor(s) for the given profile.
|
| 160 |
+
Returns empty list if the required binary is absent.
|
| 161 |
+
"""
|
| 162 |
+
cmds = _STRESSOR_CMDS.get(profile, [])
|
| 163 |
+
procs: List[subprocess.Popen] = []
|
| 164 |
+
|
| 165 |
+
for cmd in cmds:
|
| 166 |
+
binary = cmd[0]
|
| 167 |
+
if not self._binary_available(binary):
|
| 168 |
+
logger.debug("chaos_runner: binary '%s' not found — skipping stressor.", binary)
|
| 169 |
+
self._stop_stressors(procs, profile)
|
| 170 |
+
return []
|
| 171 |
+
try:
|
| 172 |
+
proc = subprocess.Popen(
|
| 173 |
+
cmd,
|
| 174 |
+
stdout=subprocess.DEVNULL,
|
| 175 |
+
stderr=subprocess.DEVNULL,
|
| 176 |
+
preexec_fn=os.setsid if os.name != "nt" else None,
|
| 177 |
+
)
|
| 178 |
+
procs.append(proc)
|
| 179 |
+
logger.debug("chaos_runner: started stressor pid=%d cmd=%s", proc.pid, cmd)
|
| 180 |
+
except Exception as exc:
|
| 181 |
+
logger.warning("chaos_runner: failed to start stressor %s: %s", cmd, exc)
|
| 182 |
+
self._stop_stressors(procs, profile)
|
| 183 |
+
return []
|
| 184 |
+
|
| 185 |
+
return procs
|
| 186 |
+
|
| 187 |
+
def _stop_stressors(
|
| 188 |
+
self, procs: List[subprocess.Popen], profile: ChaosProfile
|
| 189 |
+
) -> None:
|
| 190 |
+
"""Terminate all stressor processes and clean up network rules."""
|
| 191 |
+
for proc in procs:
|
| 192 |
+
try:
|
| 193 |
+
if os.name != "nt":
|
| 194 |
+
os.killpg(os.getpgid(proc.pid), signal.SIGTERM)
|
| 195 |
+
else:
|
| 196 |
+
proc.terminate()
|
| 197 |
+
proc.wait(timeout=5)
|
| 198 |
+
except Exception as exc:
|
| 199 |
+
logger.debug("chaos_runner: error stopping stressor pid=%s: %s", proc.pid, exc)
|
| 200 |
+
|
| 201 |
+
# Clean up tc netem rules for net-affecting profiles
|
| 202 |
+
if profile in (ChaosProfile.NET, ChaosProfile.COMPOUND):
|
| 203 |
+
if self._binary_available("tc"):
|
| 204 |
+
try:
|
| 205 |
+
subprocess.run(
|
| 206 |
+
_NET_TEARDOWN_CMD,
|
| 207 |
+
capture_output=True,
|
| 208 |
+
timeout=5,
|
| 209 |
+
check=False,
|
| 210 |
+
)
|
| 211 |
+
except Exception:
|
| 212 |
+
pass
|
| 213 |
+
|
| 214 |
+
@staticmethod
|
| 215 |
+
def _binary_available(name: str) -> bool:
|
| 216 |
+
"""Fast check: is the binary on PATH?"""
|
| 217 |
+
try:
|
| 218 |
+
subprocess.run(
|
| 219 |
+
[name, "--version"],
|
| 220 |
+
capture_output=True,
|
| 221 |
+
timeout=2,
|
| 222 |
+
check=False,
|
| 223 |
+
)
|
| 224 |
+
return True
|
| 225 |
+
except (FileNotFoundError, subprocess.TimeoutExpired):
|
| 226 |
+
return False
|
server/deep_flakiness.py
ADDED
|
@@ -0,0 +1,378 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Deep Flakiness Detection — AST-based pattern detection for five flaky-test categories.
|
| 2 |
+
"""
|
| 3 |
+
|
| 4 |
+
from __future__ import annotations
|
| 5 |
+
|
| 6 |
+
import ast
|
| 7 |
+
import re
|
| 8 |
+
from pathlib import Path
|
| 9 |
+
from typing import Any, Dict, List, Optional, Tuple
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
def detect_module_cache_pollution(repo_path: Path) -> List[str]:
|
| 13 |
+
"""Detect @lru_cache, mutable defaults, module-level global mutations.
|
| 14 |
+
|
| 15 |
+
These cause cross-test pollution when cached values carry state
|
| 16 |
+
between test invocations.
|
| 17 |
+
"""
|
| 18 |
+
violations: List[str] = []
|
| 19 |
+
|
| 20 |
+
for pyfile in repo_path.rglob("*.py"):
|
| 21 |
+
if _should_skip(pyfile):
|
| 22 |
+
continue
|
| 23 |
+
|
| 24 |
+
try:
|
| 25 |
+
source = pyfile.read_text(encoding="utf-8", errors="ignore")
|
| 26 |
+
tree = ast.parse(source)
|
| 27 |
+
except Exception:
|
| 28 |
+
continue
|
| 29 |
+
|
| 30 |
+
relative = str(pyfile.relative_to(repo_path)).replace("\\", "/")
|
| 31 |
+
reasons: List[str] = []
|
| 32 |
+
|
| 33 |
+
for node in ast.walk(tree):
|
| 34 |
+
# @lru_cache, @cache, @cached decorators
|
| 35 |
+
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
| 36 |
+
for deco in node.decorator_list:
|
| 37 |
+
deco_name = _get_decorator_name(deco)
|
| 38 |
+
if deco_name in ("lru_cache", "cache", "cached", "functools.lru_cache", "functools.cache"):
|
| 39 |
+
reasons.append(f"@{deco_name} on {node.name}")
|
| 40 |
+
|
| 41 |
+
# Mutable default arguments
|
| 42 |
+
for default in node.args.defaults + node.args.kw_defaults:
|
| 43 |
+
if default is not None and isinstance(default, (ast.List, ast.Dict, ast.Set)):
|
| 44 |
+
reasons.append(f"mutable_default in {node.name}")
|
| 45 |
+
|
| 46 |
+
# Module-level global statement
|
| 47 |
+
if isinstance(node, ast.Global):
|
| 48 |
+
reasons.append(f"global {', '.join(node.names)}")
|
| 49 |
+
|
| 50 |
+
if reasons:
|
| 51 |
+
violations.append(f"{relative}: {'; '.join(reasons[:3])}")
|
| 52 |
+
|
| 53 |
+
return violations
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
def detect_fixture_scope_leaks(repo_path: Path) -> List[str]:
|
| 57 |
+
"""Detect session/module scoped fixtures returning mutable objects without yield.
|
| 58 |
+
|
| 59 |
+
Session/module fixtures that return mutable objects (lists, dicts, sets)
|
| 60 |
+
without using yield for teardown can leak state between tests.
|
| 61 |
+
"""
|
| 62 |
+
risks: List[str] = []
|
| 63 |
+
|
| 64 |
+
for conftest in repo_path.rglob("conftest.py"):
|
| 65 |
+
if _should_skip(conftest):
|
| 66 |
+
continue
|
| 67 |
+
|
| 68 |
+
try:
|
| 69 |
+
source = conftest.read_text(encoding="utf-8", errors="ignore")
|
| 70 |
+
tree = ast.parse(source)
|
| 71 |
+
except Exception:
|
| 72 |
+
continue
|
| 73 |
+
|
| 74 |
+
relative = str(conftest.relative_to(repo_path)).replace("\\", "/")
|
| 75 |
+
|
| 76 |
+
for node in ast.walk(tree):
|
| 77 |
+
if not isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
| 78 |
+
continue
|
| 79 |
+
|
| 80 |
+
# Check if it's a pytest fixture with session/module scope
|
| 81 |
+
scope = _get_fixture_scope(node)
|
| 82 |
+
if scope not in ("session", "module"):
|
| 83 |
+
continue
|
| 84 |
+
|
| 85 |
+
# Check if it uses yield (proper teardown)
|
| 86 |
+
has_yield = any(isinstance(n, ast.Yield) for n in ast.walk(node))
|
| 87 |
+
|
| 88 |
+
# Check if it returns mutable objects
|
| 89 |
+
returns_mutable = _returns_mutable_type(node)
|
| 90 |
+
|
| 91 |
+
if returns_mutable and not has_yield:
|
| 92 |
+
risks.append(f"{relative}:{node.name} scope={scope} returns_mutable=True no_yield=True")
|
| 93 |
+
|
| 94 |
+
# Also scan test files for session/module fixtures
|
| 95 |
+
for test_file in repo_path.rglob("test_*.py"):
|
| 96 |
+
if _should_skip(test_file):
|
| 97 |
+
continue
|
| 98 |
+
|
| 99 |
+
try:
|
| 100 |
+
source = test_file.read_text(encoding="utf-8", errors="ignore")
|
| 101 |
+
tree = ast.parse(source)
|
| 102 |
+
except Exception:
|
| 103 |
+
continue
|
| 104 |
+
|
| 105 |
+
relative = str(test_file.relative_to(repo_path)).replace("\\", "/")
|
| 106 |
+
|
| 107 |
+
for node in ast.walk(tree):
|
| 108 |
+
if not isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
| 109 |
+
continue
|
| 110 |
+
|
| 111 |
+
scope = _get_fixture_scope(node)
|
| 112 |
+
if scope not in ("session", "module"):
|
| 113 |
+
continue
|
| 114 |
+
|
| 115 |
+
has_yield = any(isinstance(n, ast.Yield) for n in ast.walk(node))
|
| 116 |
+
if not has_yield:
|
| 117 |
+
risks.append(f"{relative}:{node.name} scope={scope} no_yield=True")
|
| 118 |
+
|
| 119 |
+
return risks
|
| 120 |
+
|
| 121 |
+
|
| 122 |
+
def detect_monkeypatch_residue(repo_path: Path) -> List[str]:
|
| 123 |
+
"""Detect patch() calls without proper cleanup (with-context or .stop()).
|
| 124 |
+
|
| 125 |
+
Uncleaned patches leak mock objects into subsequent tests,
|
| 126 |
+
causing hard-to-debug flakiness.
|
| 127 |
+
"""
|
| 128 |
+
violations: List[str] = []
|
| 129 |
+
|
| 130 |
+
for pyfile in repo_path.rglob("*.py"):
|
| 131 |
+
if _should_skip(pyfile):
|
| 132 |
+
continue
|
| 133 |
+
|
| 134 |
+
try:
|
| 135 |
+
source = pyfile.read_text(encoding="utf-8", errors="ignore")
|
| 136 |
+
except Exception:
|
| 137 |
+
continue
|
| 138 |
+
|
| 139 |
+
relative = str(pyfile.relative_to(repo_path)).replace("\\", "/")
|
| 140 |
+
|
| 141 |
+
# Find patch() calls not inside with-blocks and without .stop()
|
| 142 |
+
if "patch(" in source:
|
| 143 |
+
lines = source.split("\n")
|
| 144 |
+
for i, line in enumerate(lines, 1):
|
| 145 |
+
stripped = line.strip()
|
| 146 |
+
# Skip comment lines
|
| 147 |
+
if stripped.startswith("#"):
|
| 148 |
+
continue
|
| 149 |
+
|
| 150 |
+
if "patch(" in stripped:
|
| 151 |
+
# Check if it's inside a with block
|
| 152 |
+
if stripped.startswith("with ") or "with mock.patch" in stripped:
|
| 153 |
+
continue
|
| 154 |
+
# Check if it's a decorator
|
| 155 |
+
if stripped.startswith("@"):
|
| 156 |
+
continue
|
| 157 |
+
# Check if .stop() is called nearby
|
| 158 |
+
window = "\n".join(lines[max(0, i - 1):min(len(lines), i + 10)])
|
| 159 |
+
if ".stop()" in window:
|
| 160 |
+
continue
|
| 161 |
+
|
| 162 |
+
violations.append(f"{relative}:{i} — patch() without with-context or .stop()")
|
| 163 |
+
|
| 164 |
+
return violations
|
| 165 |
+
|
| 166 |
+
|
| 167 |
+
def detect_import_side_effects(repo_path: Path) -> List[str]:
|
| 168 |
+
"""Detect top-level module code with non-constant expressions.
|
| 169 |
+
|
| 170 |
+
Top-level code that performs I/O, makes network calls, or reads
|
| 171 |
+
configuration executes on import, causing non-deterministic behavior.
|
| 172 |
+
"""
|
| 173 |
+
violations: List[str] = []
|
| 174 |
+
|
| 175 |
+
for pyfile in repo_path.rglob("*.py"):
|
| 176 |
+
if _should_skip(pyfile):
|
| 177 |
+
continue
|
| 178 |
+
|
| 179 |
+
try:
|
| 180 |
+
source = pyfile.read_text(encoding="utf-8", errors="ignore")
|
| 181 |
+
tree = ast.parse(source)
|
| 182 |
+
except Exception:
|
| 183 |
+
continue
|
| 184 |
+
|
| 185 |
+
relative = str(pyfile.relative_to(repo_path)).replace("\\", "/")
|
| 186 |
+
reasons: List[str] = []
|
| 187 |
+
|
| 188 |
+
for node in tree.body:
|
| 189 |
+
# Skip harmless top-level constructs
|
| 190 |
+
if isinstance(node, (
|
| 191 |
+
ast.Import, ast.ImportFrom, ast.FunctionDef, ast.AsyncFunctionDef,
|
| 192 |
+
ast.ClassDef, ast.If, ast.Pass, ast.Expr,
|
| 193 |
+
)):
|
| 194 |
+
# Check if it's a bare expression with side effects
|
| 195 |
+
if isinstance(node, ast.Expr) and isinstance(node.value, ast.Call):
|
| 196 |
+
call_name = _get_call_name(node.value)
|
| 197 |
+
# Harmless calls
|
| 198 |
+
if call_name not in ("print", "logging.getLogger", "warnings.warn"):
|
| 199 |
+
reasons.append(f"top-level call: {call_name}()")
|
| 200 |
+
continue
|
| 201 |
+
|
| 202 |
+
# Top-level assignments to mutable objects from function calls
|
| 203 |
+
if isinstance(node, ast.Assign):
|
| 204 |
+
for target in node.targets:
|
| 205 |
+
if isinstance(target, ast.Name):
|
| 206 |
+
if isinstance(node.value, ast.Call):
|
| 207 |
+
call_name = _get_call_name(node.value)
|
| 208 |
+
if call_name and "." in call_name:
|
| 209 |
+
reasons.append(f"{target.id} = {call_name}()")
|
| 210 |
+
|
| 211 |
+
if reasons:
|
| 212 |
+
violations.append(f"{relative}: {'; '.join(reasons[:3])}")
|
| 213 |
+
|
| 214 |
+
return violations
|
| 215 |
+
|
| 216 |
+
|
| 217 |
+
def detect_async_contamination(repo_path: Path) -> bool:
|
| 218 |
+
"""Detect patterns that indicate async tasks/threads may survive past test boundaries.
|
| 219 |
+
|
| 220 |
+
Looks for: fire-and-forget asyncio.create_task(), threading.Thread().start()
|
| 221 |
+
without corresponding join/await, daemon threads, etc.
|
| 222 |
+
"""
|
| 223 |
+
for pyfile in repo_path.rglob("*.py"):
|
| 224 |
+
if _should_skip(pyfile):
|
| 225 |
+
continue
|
| 226 |
+
|
| 227 |
+
try:
|
| 228 |
+
source = pyfile.read_text(encoding="utf-8", errors="ignore")
|
| 229 |
+
except Exception:
|
| 230 |
+
continue
|
| 231 |
+
|
| 232 |
+
# Fire-and-forget patterns
|
| 233 |
+
if re.search(r"asyncio\.create_task\(", source):
|
| 234 |
+
if "await" not in source or "gather" not in source:
|
| 235 |
+
return True
|
| 236 |
+
|
| 237 |
+
if re.search(r"\.start\(\)", source) and "threading" in source:
|
| 238 |
+
if ".join()" not in source:
|
| 239 |
+
return True
|
| 240 |
+
|
| 241 |
+
return False
|
| 242 |
+
|
| 243 |
+
|
| 244 |
+
def build_deep_observation_signals(repo_path: Path) -> Dict[str, Any]:
|
| 245 |
+
"""Build all deep flakiness signals for the observation.
|
| 246 |
+
|
| 247 |
+
This is designed to run in <5ms via AST scanning only.
|
| 248 |
+
"""
|
| 249 |
+
return {
|
| 250 |
+
"module_cache_violations": detect_module_cache_pollution(repo_path),
|
| 251 |
+
"fixture_scope_risks": detect_fixture_scope_leaks(repo_path),
|
| 252 |
+
"mock_residue_sites": detect_monkeypatch_residue(repo_path),
|
| 253 |
+
"import_side_effect_files": detect_import_side_effects(repo_path),
|
| 254 |
+
"async_contamination_alive": detect_async_contamination(repo_path),
|
| 255 |
+
}
|
| 256 |
+
|
| 257 |
+
|
| 258 |
+
def extract_failure_frontier(
|
| 259 |
+
failing_trace: str,
|
| 260 |
+
repo_path: Path,
|
| 261 |
+
) -> Tuple[str, List[str], List[str]]:
|
| 262 |
+
"""Extract the deepest user-code frame from a stack trace.
|
| 263 |
+
|
| 264 |
+
Returns:
|
| 265 |
+
(failure_frontier, call_chain, boundary_crossings)
|
| 266 |
+
"""
|
| 267 |
+
if not failing_trace:
|
| 268 |
+
return "", [], []
|
| 269 |
+
|
| 270 |
+
# Parse stack trace lines for file:line references
|
| 271 |
+
frame_pattern = re.compile(r'File "([^"]+)", line (\d+), in (\w+)')
|
| 272 |
+
frames: List[Dict[str, str]] = []
|
| 273 |
+
|
| 274 |
+
for match in frame_pattern.finditer(failing_trace):
|
| 275 |
+
file_path, line_num, func_name = match.groups()
|
| 276 |
+
frames.append({
|
| 277 |
+
"file": file_path,
|
| 278 |
+
"line": line_num,
|
| 279 |
+
"function": func_name,
|
| 280 |
+
})
|
| 281 |
+
|
| 282 |
+
if not frames:
|
| 283 |
+
return "", [], []
|
| 284 |
+
|
| 285 |
+
# Find deepest user-code frame (not stdlib/site-packages)
|
| 286 |
+
user_frames = [
|
| 287 |
+
f for f in frames
|
| 288 |
+
if "site-packages" not in f["file"]
|
| 289 |
+
and "lib/python" not in f["file"]
|
| 290 |
+
and not f["file"].startswith("<")
|
| 291 |
+
]
|
| 292 |
+
|
| 293 |
+
if not user_frames:
|
| 294 |
+
user_frames = frames
|
| 295 |
+
|
| 296 |
+
# Build call chain
|
| 297 |
+
call_chain = [f"{f['function']}" for f in user_frames]
|
| 298 |
+
failure_frontier = f"{user_frames[-1]['file']}:{user_frames[-1]['line']}:{user_frames[-1]['function']}"
|
| 299 |
+
|
| 300 |
+
# Detect boundary crossings
|
| 301 |
+
boundary_crossings: List[str] = []
|
| 302 |
+
for f in user_frames:
|
| 303 |
+
func = f["function"].lower()
|
| 304 |
+
if any(kw in func for kw in ("request", "http", "fetch", "get", "post")):
|
| 305 |
+
boundary_crossings.append(f"http:{f['function']}")
|
| 306 |
+
elif any(kw in func for kw in ("query", "execute", "commit", "cursor")):
|
| 307 |
+
boundary_crossings.append(f"db:{f['function']}")
|
| 308 |
+
elif any(kw in func for kw in ("send", "publish", "enqueue")):
|
| 309 |
+
boundary_crossings.append(f"queue:{f['function']}")
|
| 310 |
+
|
| 311 |
+
return failure_frontier, call_chain, list(set(boundary_crossings))
|
| 312 |
+
|
| 313 |
+
|
| 314 |
+
# ── Internal Helpers ──────────────────────────────────────────────────────────
|
| 315 |
+
|
| 316 |
+
def _should_skip(path: Path) -> bool:
|
| 317 |
+
"""Skip non-project files."""
|
| 318 |
+
skip_dirs = {"__pycache__", ".git", "node_modules", "venv", ".venv", ".pytest_cache"}
|
| 319 |
+
return any(part in skip_dirs for part in path.parts)
|
| 320 |
+
|
| 321 |
+
|
| 322 |
+
def _get_decorator_name(deco: ast.expr) -> str:
|
| 323 |
+
"""Extract decorator name from AST node."""
|
| 324 |
+
if isinstance(deco, ast.Name):
|
| 325 |
+
return deco.id
|
| 326 |
+
if isinstance(deco, ast.Attribute):
|
| 327 |
+
parts = []
|
| 328 |
+
current = deco
|
| 329 |
+
while isinstance(current, ast.Attribute):
|
| 330 |
+
parts.append(current.attr)
|
| 331 |
+
current = current.value
|
| 332 |
+
if isinstance(current, ast.Name):
|
| 333 |
+
parts.append(current.id)
|
| 334 |
+
return ".".join(reversed(parts))
|
| 335 |
+
if isinstance(deco, ast.Call):
|
| 336 |
+
return _get_decorator_name(deco.func)
|
| 337 |
+
return ""
|
| 338 |
+
|
| 339 |
+
|
| 340 |
+
def _get_fixture_scope(node: ast.FunctionDef) -> str:
|
| 341 |
+
"""Get the scope of a pytest fixture from its decorators."""
|
| 342 |
+
for deco in node.decorator_list:
|
| 343 |
+
if isinstance(deco, ast.Call):
|
| 344 |
+
deco_name = _get_decorator_name(deco.func)
|
| 345 |
+
if deco_name in ("pytest.fixture", "fixture"):
|
| 346 |
+
for keyword in deco.keywords:
|
| 347 |
+
if keyword.arg == "scope" and isinstance(keyword.value, ast.Constant):
|
| 348 |
+
return str(keyword.value.value)
|
| 349 |
+
return "function"
|
| 350 |
+
|
| 351 |
+
|
| 352 |
+
def _returns_mutable_type(node: ast.FunctionDef) -> bool:
|
| 353 |
+
"""Check if a function returns mutable types (list, dict, set)."""
|
| 354 |
+
for child in ast.walk(node):
|
| 355 |
+
if isinstance(child, ast.Return) and child.value is not None:
|
| 356 |
+
if isinstance(child.value, (ast.List, ast.Dict, ast.Set)):
|
| 357 |
+
return True
|
| 358 |
+
if isinstance(child.value, ast.Call):
|
| 359 |
+
call_name = _get_call_name(child.value)
|
| 360 |
+
if call_name in ("dict", "list", "set", "defaultdict", "OrderedDict"):
|
| 361 |
+
return True
|
| 362 |
+
return False
|
| 363 |
+
|
| 364 |
+
|
| 365 |
+
def _get_call_name(call: ast.Call) -> str:
|
| 366 |
+
"""Get the name of a function call."""
|
| 367 |
+
if isinstance(call.func, ast.Name):
|
| 368 |
+
return call.func.id
|
| 369 |
+
if isinstance(call.func, ast.Attribute):
|
| 370 |
+
parts = []
|
| 371 |
+
current = call.func
|
| 372 |
+
while isinstance(current, ast.Attribute):
|
| 373 |
+
parts.append(current.attr)
|
| 374 |
+
current = current.value
|
| 375 |
+
if isinstance(current, ast.Name):
|
| 376 |
+
parts.append(current.id)
|
| 377 |
+
return ".".join(reversed(parts))
|
| 378 |
+
return ""
|
server/docker_runner.py
ADDED
|
@@ -0,0 +1,371 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import concurrent.futures
|
| 4 |
+
import logging
|
| 5 |
+
import os
|
| 6 |
+
import re
|
| 7 |
+
import subprocess
|
| 8 |
+
import sys
|
| 9 |
+
import time
|
| 10 |
+
from pathlib import Path
|
| 11 |
+
from typing import List
|
| 12 |
+
|
| 13 |
+
try:
|
| 14 |
+
from ..models import RunRecord
|
| 15 |
+
except ImportError:
|
| 16 |
+
from models import RunRecord
|
| 17 |
+
|
| 18 |
+
logger = logging.getLogger(__name__)
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
class DockerTestRunner:
|
| 22 |
+
"""Runs pytest either locally or inside a Docker sandbox.
|
| 23 |
+
|
| 24 |
+
Mode selection is controlled by environment variables:
|
| 25 |
+
- USE_DOCKER_IMAGE=1 enables sandbox mode
|
| 26 |
+
- LOCAL_IMAGE_NAME sets the image (default: flakeforge-env:latest)
|
| 27 |
+
"""
|
| 28 |
+
|
| 29 |
+
# Resolve the FlakeForge repo root once. We must keep its `__init__.py`
|
| 30 |
+
# off the pytest import path for seed-repo subprocesses, otherwise pytest
|
| 31 |
+
# walks up to the FlakeForge `pytest.ini` and tries to import the heavy
|
| 32 |
+
# FlakeForge package as a "test module" — surfacing as bogus
|
| 33 |
+
# `ModuleNotFoundError` (e.g. typeguard / openenv) and a false
|
| 34 |
+
# `infra_broken` preflight verdict for every seed repo.
|
| 35 |
+
_FF_REPO_ROOT = Path(__file__).resolve().parent.parent
|
| 36 |
+
|
| 37 |
+
def __init__(self, repo_path: str) -> None:
|
| 38 |
+
self.repo_path = Path(repo_path)
|
| 39 |
+
self.use_docker_image = os.getenv("USE_DOCKER_IMAGE", "0").strip().lower() in {"1", "true", "yes"}
|
| 40 |
+
self.local_image_name = os.getenv("LOCAL_IMAGE_NAME", "flakeforge-env:latest").strip() or "flakeforge-env:latest"
|
| 41 |
+
self.pytest_timeout_seconds = int(os.getenv("FF_PYTEST_TIMEOUT_SECONDS", "20") or "20")
|
| 42 |
+
self._deps_checked = False
|
| 43 |
+
self._deps_ready = False
|
| 44 |
+
self._deps_error = ""
|
| 45 |
+
self._docker_checked = False
|
| 46 |
+
self._docker_available = False
|
| 47 |
+
self._docker_unavailable_reason = ""
|
| 48 |
+
self._warned_unavailable = False
|
| 49 |
+
|
| 50 |
+
def _ensure_docker_available(self) -> bool:
|
| 51 |
+
"""One-time probe for docker CLI and image presence."""
|
| 52 |
+
if self._docker_checked:
|
| 53 |
+
return self._docker_available
|
| 54 |
+
|
| 55 |
+
self._docker_checked = True
|
| 56 |
+
try:
|
| 57 |
+
cli = subprocess.run(
|
| 58 |
+
["docker", "version", "--format", "{{.Server.Version}}"],
|
| 59 |
+
capture_output=True,
|
| 60 |
+
text=True,
|
| 61 |
+
timeout=5,
|
| 62 |
+
)
|
| 63 |
+
if cli.returncode != 0:
|
| 64 |
+
self._docker_available = False
|
| 65 |
+
self._docker_unavailable_reason = (cli.stderr or cli.stdout or "docker CLI probe failed").strip()
|
| 66 |
+
return False
|
| 67 |
+
|
| 68 |
+
img = subprocess.run(
|
| 69 |
+
["docker", "image", "inspect", self.local_image_name],
|
| 70 |
+
capture_output=True,
|
| 71 |
+
text=True,
|
| 72 |
+
timeout=5,
|
| 73 |
+
)
|
| 74 |
+
self._docker_available = img.returncode == 0
|
| 75 |
+
if not self._docker_available:
|
| 76 |
+
self._docker_unavailable_reason = (
|
| 77 |
+
img.stderr or img.stdout or f"docker image '{self.local_image_name}' not found"
|
| 78 |
+
).strip()
|
| 79 |
+
return self._docker_available
|
| 80 |
+
except Exception as exc:
|
| 81 |
+
self._docker_available = False
|
| 82 |
+
self._docker_unavailable_reason = f"exception while probing docker: {exc}"
|
| 83 |
+
return False
|
| 84 |
+
|
| 85 |
+
def _maybe_warn_docker_unavailable(self) -> None:
|
| 86 |
+
if self.use_docker_image and not self._warned_unavailable:
|
| 87 |
+
self._warned_unavailable = True
|
| 88 |
+
logger.warning(
|
| 89 |
+
"USE_DOCKER_IMAGE is enabled but sandbox execution is unavailable; falling back to local pytest. reason=%s",
|
| 90 |
+
self._docker_unavailable_reason or "unknown",
|
| 91 |
+
)
|
| 92 |
+
|
| 93 |
+
def _pytest_cmd(self, test_id: str) -> List[str]:
|
| 94 |
+
# Pin rootdir/confcutdir to the seed repo so pytest never escapes up
|
| 95 |
+
# into the FlakeForge tree. `--import-mode=importlib` skips parent-dir
|
| 96 |
+
# injection into sys.path, which is what previously caused FlakeForge
|
| 97 |
+
# to be imported as part of the test package chain.
|
| 98 |
+
abs_repo = str(self.repo_path.resolve())
|
| 99 |
+
return [
|
| 100 |
+
sys.executable, "-W", "ignore::SyntaxWarning",
|
| 101 |
+
"-m", "pytest", test_id,
|
| 102 |
+
"--rootdir", abs_repo,
|
| 103 |
+
"--confcutdir", abs_repo,
|
| 104 |
+
"--import-mode=importlib",
|
| 105 |
+
"-p", "no:cacheprovider",
|
| 106 |
+
"-W", "ignore::SyntaxWarning",
|
| 107 |
+
"--tb=short", "-q", "--no-header",
|
| 108 |
+
]
|
| 109 |
+
|
| 110 |
+
def _isolated_env(self) -> dict[str, str]:
|
| 111 |
+
"""Return a subprocess env that hides the FlakeForge repo from pytest.
|
| 112 |
+
|
| 113 |
+
Even with rootdir/confcutdir pinned, an inherited PYTHONPATH that
|
| 114 |
+
contains the FlakeForge root will still let pytest discover and
|
| 115 |
+
import the FlakeForge package on `import FlakeForge` style probes.
|
| 116 |
+
We strip it here, and disable bytecode writes to keep seed repos
|
| 117 |
+
clean (no stray `__pycache__` dirs polluting git status).
|
| 118 |
+
"""
|
| 119 |
+
env = os.environ.copy()
|
| 120 |
+
ff_root_norm = os.path.normcase(str(self._FF_REPO_ROOT.resolve()))
|
| 121 |
+
pp = env.get("PYTHONPATH", "")
|
| 122 |
+
if pp:
|
| 123 |
+
sep = os.pathsep
|
| 124 |
+
kept = []
|
| 125 |
+
for part in pp.split(sep):
|
| 126 |
+
if not part:
|
| 127 |
+
continue
|
| 128 |
+
try:
|
| 129 |
+
norm = os.path.normcase(str(Path(part).resolve()))
|
| 130 |
+
except Exception:
|
| 131 |
+
norm = os.path.normcase(part)
|
| 132 |
+
if norm != ff_root_norm:
|
| 133 |
+
kept.append(part)
|
| 134 |
+
if kept:
|
| 135 |
+
env["PYTHONPATH"] = sep.join(kept)
|
| 136 |
+
else:
|
| 137 |
+
env.pop("PYTHONPATH", None)
|
| 138 |
+
env["PYTHONDONTWRITEBYTECODE"] = "1"
|
| 139 |
+
env["PYTHONWARNINGS"] = "ignore::SyntaxWarning"
|
| 140 |
+
return env
|
| 141 |
+
|
| 142 |
+
def _deps_marker_path(self) -> Path:
|
| 143 |
+
return self.repo_path / ".flakeforge_deps_ready"
|
| 144 |
+
|
| 145 |
+
def _ensure_local_deps(self) -> bool:
|
| 146 |
+
"""Best-effort install of repo-specific test dependencies (local mode only).
|
| 147 |
+
|
| 148 |
+
Many IDoFT repos require dependencies to import modules during pytest
|
| 149 |
+
collection. We treat pytest itself as critical (hard fail) but allow
|
| 150 |
+
requirements / editable-install failures to be non-fatal: most seed
|
| 151 |
+
repos work fine without them, and a failed `pip install -e .` should
|
| 152 |
+
never prevent the test from being attempted at all.
|
| 153 |
+
"""
|
| 154 |
+
if self._deps_checked:
|
| 155 |
+
return self._deps_ready
|
| 156 |
+
|
| 157 |
+
self._deps_checked = True
|
| 158 |
+
marker = self._deps_marker_path()
|
| 159 |
+
if marker.exists():
|
| 160 |
+
self._deps_ready = True
|
| 161 |
+
return True
|
| 162 |
+
|
| 163 |
+
try:
|
| 164 |
+
# Step 1 (critical): ensure pytest is available.
|
| 165 |
+
proc = subprocess.run(
|
| 166 |
+
[sys.executable, "-m", "pip", "install", "-q", "pytest"],
|
| 167 |
+
capture_output=True,
|
| 168 |
+
text=True,
|
| 169 |
+
cwd=self.repo_path,
|
| 170 |
+
timeout=300,
|
| 171 |
+
)
|
| 172 |
+
if proc.returncode != 0:
|
| 173 |
+
self._deps_ready = False
|
| 174 |
+
combined = f"{proc.stdout}\n{proc.stderr}".strip()
|
| 175 |
+
self._deps_error = (combined or "pytest install failed")[-800:]
|
| 176 |
+
return False
|
| 177 |
+
|
| 178 |
+
# Step 2 (best-effort): install declared requirements only.
|
| 179 |
+
# NEVER do `pip install -e .` on seed repos — that pollutes the
|
| 180 |
+
# .venv's site-packages with editable links to every repo,
|
| 181 |
+
# causing massive cross-contamination between unrelated projects.
|
| 182 |
+
requirements = self.repo_path / "requirements.txt"
|
| 183 |
+
req_test = self.repo_path / "requirements-test.txt"
|
| 184 |
+
|
| 185 |
+
extra_cmds: list[list[str]] = []
|
| 186 |
+
if requirements.exists():
|
| 187 |
+
extra_cmds.append([sys.executable, "-m", "pip", "install", "-q", "-r", "requirements.txt"])
|
| 188 |
+
if req_test.exists():
|
| 189 |
+
extra_cmds.append([sys.executable, "-m", "pip", "install", "-q", "-r", "requirements-test.txt"])
|
| 190 |
+
|
| 191 |
+
for cmd in extra_cmds:
|
| 192 |
+
try:
|
| 193 |
+
subprocess.run(
|
| 194 |
+
cmd,
|
| 195 |
+
capture_output=True,
|
| 196 |
+
text=True,
|
| 197 |
+
cwd=self.repo_path,
|
| 198 |
+
timeout=300,
|
| 199 |
+
)
|
| 200 |
+
except Exception:
|
| 201 |
+
pass
|
| 202 |
+
|
| 203 |
+
marker.write_text("ok\n", encoding="utf-8")
|
| 204 |
+
self._deps_ready = True
|
| 205 |
+
return True
|
| 206 |
+
except Exception as exc:
|
| 207 |
+
self._deps_ready = False
|
| 208 |
+
self._deps_error = f"{type(exc).__name__}: {exc}"
|
| 209 |
+
return False
|
| 210 |
+
|
| 211 |
+
_CREATION_FLAGS = (
|
| 212 |
+
(subprocess.CREATE_NEW_PROCESS_GROUP | subprocess.CREATE_NO_WINDOW)
|
| 213 |
+
if sys.platform == "win32" else 0
|
| 214 |
+
)
|
| 215 |
+
|
| 216 |
+
def _run_local_pytest(self, test_id: str, timeout_seconds: int) -> subprocess.CompletedProcess[str]:
|
| 217 |
+
return subprocess.run(
|
| 218 |
+
self._pytest_cmd(test_id),
|
| 219 |
+
capture_output=True,
|
| 220 |
+
text=True,
|
| 221 |
+
timeout=timeout_seconds,
|
| 222 |
+
cwd=self.repo_path,
|
| 223 |
+
env=self._isolated_env(),
|
| 224 |
+
creationflags=self._CREATION_FLAGS,
|
| 225 |
+
)
|
| 226 |
+
|
| 227 |
+
def _run_docker_pytest(self, test_id: str, timeout_seconds: int) -> subprocess.CompletedProcess[str]:
|
| 228 |
+
mount_src = str(self.repo_path.resolve())
|
| 229 |
+
cmd = [
|
| 230 |
+
"docker", "run", "--rm",
|
| 231 |
+
"--network", "none",
|
| 232 |
+
"--cpus", os.getenv("FF_DOCKER_CPUS", "1.0"),
|
| 233 |
+
"--memory", os.getenv("FF_DOCKER_MEMORY", "2g"),
|
| 234 |
+
"-v", f"{mount_src}:/workspace",
|
| 235 |
+
"-w", "/workspace",
|
| 236 |
+
"-e", "PYTHONDONTWRITEBYTECODE=1",
|
| 237 |
+
self.local_image_name,
|
| 238 |
+
*self._pytest_cmd(test_id),
|
| 239 |
+
]
|
| 240 |
+
return subprocess.run(
|
| 241 |
+
cmd,
|
| 242 |
+
capture_output=True,
|
| 243 |
+
text=True,
|
| 244 |
+
timeout=timeout_seconds,
|
| 245 |
+
cwd=self.repo_path,
|
| 246 |
+
)
|
| 247 |
+
|
| 248 |
+
def run_test(self, test_id: str, timeout_seconds: int | None = None) -> RunRecord:
|
| 249 |
+
start = time.perf_counter()
|
| 250 |
+
timeout_seconds = int(timeout_seconds or self.pytest_timeout_seconds)
|
| 251 |
+
try:
|
| 252 |
+
if self.use_docker_image and self._ensure_docker_available():
|
| 253 |
+
proc = self._run_docker_pytest(test_id, timeout_seconds)
|
| 254 |
+
else:
|
| 255 |
+
if self.use_docker_image:
|
| 256 |
+
self._maybe_warn_docker_unavailable()
|
| 257 |
+
if not self._ensure_local_deps():
|
| 258 |
+
duration_ms = int((time.perf_counter() - start) * 1000)
|
| 259 |
+
return RunRecord(
|
| 260 |
+
passed=False,
|
| 261 |
+
duration_ms=duration_ms,
|
| 262 |
+
error_type="ImportError",
|
| 263 |
+
error_message="dependency_install_failed",
|
| 264 |
+
stderr_excerpt=(self._deps_error or "")[-500:],
|
| 265 |
+
)
|
| 266 |
+
proc = self._run_local_pytest(test_id, timeout_seconds)
|
| 267 |
+
duration_ms = int((time.perf_counter() - start) * 1000)
|
| 268 |
+
output = f"{proc.stdout}\n{proc.stderr}".strip()
|
| 269 |
+
passed = proc.returncode == 0
|
| 270 |
+
error_type = self._extract_error_type(output)
|
| 271 |
+
error_message = self._extract_error_message(output)
|
| 272 |
+
return RunRecord(
|
| 273 |
+
passed=passed,
|
| 274 |
+
duration_ms=duration_ms,
|
| 275 |
+
error_type=error_type,
|
| 276 |
+
error_message=error_message,
|
| 277 |
+
stderr_excerpt=(proc.stderr or "")[-500:],
|
| 278 |
+
)
|
| 279 |
+
except subprocess.TimeoutExpired:
|
| 280 |
+
duration_ms = int((time.perf_counter() - start) * 1000)
|
| 281 |
+
return RunRecord(
|
| 282 |
+
passed=False,
|
| 283 |
+
duration_ms=duration_ms,
|
| 284 |
+
error_type="TimeoutError",
|
| 285 |
+
error_message=f"pytest timed out after {timeout_seconds}s",
|
| 286 |
+
stderr_excerpt=None,
|
| 287 |
+
)
|
| 288 |
+
except Exception as exc: # pragma: no cover
|
| 289 |
+
duration_ms = int((time.perf_counter() - start) * 1000)
|
| 290 |
+
return RunRecord(
|
| 291 |
+
passed=False,
|
| 292 |
+
duration_ms=duration_ms,
|
| 293 |
+
error_type=type(exc).__name__,
|
| 294 |
+
error_message=str(exc),
|
| 295 |
+
stderr_excerpt=None,
|
| 296 |
+
)
|
| 297 |
+
|
| 298 |
+
def run_test_n_times(self, test_id: str, n: int, max_workers: int = 4) -> List[RunRecord]:
|
| 299 |
+
with concurrent.futures.ThreadPoolExecutor(max_workers=max_workers) as executor:
|
| 300 |
+
return list(executor.map(lambda _: self.run_test(test_id), range(n)))
|
| 301 |
+
|
| 302 |
+
def check_regressions(self, exclude_test_id: str, timeout_seconds: int = 30) -> bool:
|
| 303 |
+
exclude_test_file = exclude_test_id.split("::", 1)[0]
|
| 304 |
+
repo_root = self.repo_path.resolve()
|
| 305 |
+
tests_root = repo_root / "tests"
|
| 306 |
+
if not tests_root.exists():
|
| 307 |
+
return False
|
| 308 |
+
|
| 309 |
+
try:
|
| 310 |
+
cmd = [
|
| 311 |
+
sys.executable, "-W", "ignore::SyntaxWarning",
|
| 312 |
+
"-m", "pytest",
|
| 313 |
+
str(tests_root),
|
| 314 |
+
f"--ignore={repo_root / exclude_test_file}",
|
| 315 |
+
"--rootdir", str(repo_root),
|
| 316 |
+
"--confcutdir", str(repo_root),
|
| 317 |
+
"--import-mode=importlib",
|
| 318 |
+
"-p", "no:cacheprovider",
|
| 319 |
+
"-W", "ignore::SyntaxWarning",
|
| 320 |
+
"-x",
|
| 321 |
+
"-q",
|
| 322 |
+
]
|
| 323 |
+
if self.use_docker_image and self._ensure_docker_available():
|
| 324 |
+
mount_src = str(repo_root)
|
| 325 |
+
proc = subprocess.run(
|
| 326 |
+
[
|
| 327 |
+
"docker", "run", "--rm",
|
| 328 |
+
"--network", "none",
|
| 329 |
+
"--cpus", os.getenv("FF_DOCKER_CPUS", "1.0"),
|
| 330 |
+
"--memory", os.getenv("FF_DOCKER_MEMORY", "2g"),
|
| 331 |
+
"-v", f"{mount_src}:/workspace",
|
| 332 |
+
"-w", "/workspace",
|
| 333 |
+
"-e", "PYTHONDONTWRITEBYTECODE=1",
|
| 334 |
+
self.local_image_name,
|
| 335 |
+
*cmd,
|
| 336 |
+
],
|
| 337 |
+
capture_output=True,
|
| 338 |
+
text=True,
|
| 339 |
+
timeout=timeout_seconds,
|
| 340 |
+
cwd=repo_root,
|
| 341 |
+
)
|
| 342 |
+
else:
|
| 343 |
+
if self.use_docker_image:
|
| 344 |
+
self._maybe_warn_docker_unavailable()
|
| 345 |
+
proc = subprocess.run(
|
| 346 |
+
cmd,
|
| 347 |
+
capture_output=True,
|
| 348 |
+
text=True,
|
| 349 |
+
timeout=timeout_seconds,
|
| 350 |
+
cwd=repo_root,
|
| 351 |
+
env=self._isolated_env(),
|
| 352 |
+
creationflags=self._CREATION_FLAGS,
|
| 353 |
+
)
|
| 354 |
+
# pytest exits with code 5 when every test file was excluded and
|
| 355 |
+
# no tests were collected. That is not a regression; it just means
|
| 356 |
+
# this tiny target repo only has the flaky test file.
|
| 357 |
+
return proc.returncode not in (0, 5)
|
| 358 |
+
except subprocess.TimeoutExpired:
|
| 359 |
+
return True
|
| 360 |
+
|
| 361 |
+
@staticmethod
|
| 362 |
+
def _extract_error_type(output: str) -> str | None:
|
| 363 |
+
match = re.search(r"([A-Za-z_][A-Za-z0-9_]*Error|Exception)\b", output)
|
| 364 |
+
return match.group(1) if match else None
|
| 365 |
+
|
| 366 |
+
@staticmethod
|
| 367 |
+
def _extract_error_message(output: str) -> str | None:
|
| 368 |
+
lines = [line.strip() for line in output.splitlines() if line.strip()]
|
| 369 |
+
if not lines:
|
| 370 |
+
return None
|
| 371 |
+
return lines[-1][:200]
|
server/oracle_engine.py
ADDED
|
@@ -0,0 +1,886 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Oracle Engine — static and patch-coherence verification for structured claims."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import ast
|
| 6 |
+
import logging
|
| 7 |
+
import re
|
| 8 |
+
from abc import ABC, abstractmethod
|
| 9 |
+
from dataclasses import dataclass, field
|
| 10 |
+
from pathlib import Path
|
| 11 |
+
from typing import Any, Dict, Iterable, List, Optional, Sequence, Tuple
|
| 12 |
+
|
| 13 |
+
try:
|
| 14 |
+
import libcst as cst
|
| 15 |
+
import libcst.matchers as m
|
| 16 |
+
import libcst.metadata as meta
|
| 17 |
+
_LIBCST_AVAILABLE = True
|
| 18 |
+
except ImportError:
|
| 19 |
+
cst = None # type: ignore[assignment]
|
| 20 |
+
m = None # type: ignore[assignment]
|
| 21 |
+
meta = None # type: ignore[assignment]
|
| 22 |
+
_LIBCST_AVAILABLE = False
|
| 23 |
+
|
| 24 |
+
_CST_VISITOR_BASE = cst.CSTVisitor if cst is not None else object
|
| 25 |
+
|
| 26 |
+
try:
|
| 27 |
+
from models import PatchHunk, StructuredThink, ThinkClaim
|
| 28 |
+
except ImportError:
|
| 29 |
+
from ..models import PatchHunk, StructuredThink, ThinkClaim
|
| 30 |
+
|
| 31 |
+
logger = logging.getLogger(__name__)
|
| 32 |
+
|
| 33 |
+
_SYNC_PRIMITIVES = {
|
| 34 |
+
"asyncio.Lock",
|
| 35 |
+
"asyncio.Semaphore",
|
| 36 |
+
"asyncio.Event",
|
| 37 |
+
"threading.Lock",
|
| 38 |
+
"threading.RLock",
|
| 39 |
+
"threading.Semaphore",
|
| 40 |
+
"anyio.Lock",
|
| 41 |
+
"trio.Lock",
|
| 42 |
+
}
|
| 43 |
+
|
| 44 |
+
_SYNC_ATTRS = {"Lock", "RLock", "Semaphore", "Event", "Condition"}
|
| 45 |
+
_MUTATING_METHODS = {"append", "extend", "pop", "clear", "update", "add", "remove", "discard", "setdefault"}
|
| 46 |
+
_BLOCKING_SYNC_CALLS = {"time.sleep", "requests.get", "requests.post", "urllib.request.urlopen"}
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
@dataclass
|
| 50 |
+
class OracleEvidence:
|
| 51 |
+
entity_resolved: bool = False
|
| 52 |
+
pre_condition_met: bool = False
|
| 53 |
+
post_condition_met: bool = False
|
| 54 |
+
patch_addresses_claim: bool = False
|
| 55 |
+
dynamic_confirmed: bool = False
|
| 56 |
+
notes: List[str] = field(default_factory=list)
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
class _SourcePair:
|
| 60 |
+
"""Pre-patch and post-patch source text for a single file."""
|
| 61 |
+
|
| 62 |
+
def __init__(self, pre: str, post: str) -> None:
|
| 63 |
+
self.pre_src = pre
|
| 64 |
+
self.post_src = post
|
| 65 |
+
self._pre_tree: Optional[ast.AST] = None
|
| 66 |
+
self._post_tree: Optional[ast.AST] = None
|
| 67 |
+
|
| 68 |
+
def pre_ast(self) -> Optional[ast.AST]:
|
| 69 |
+
if self._pre_tree is None and self.pre_src:
|
| 70 |
+
try:
|
| 71 |
+
self._pre_tree = ast.parse(self.pre_src)
|
| 72 |
+
except SyntaxError:
|
| 73 |
+
pass
|
| 74 |
+
return self._pre_tree
|
| 75 |
+
|
| 76 |
+
def post_ast(self) -> Optional[ast.AST]:
|
| 77 |
+
if self._post_tree is None and self.post_src:
|
| 78 |
+
try:
|
| 79 |
+
self._post_tree = ast.parse(self.post_src)
|
| 80 |
+
except SyntaxError:
|
| 81 |
+
pass
|
| 82 |
+
return self._post_tree
|
| 83 |
+
|
| 84 |
+
|
| 85 |
+
def _build_source_map(
|
| 86 |
+
pre_sources: Dict[str, str],
|
| 87 |
+
post_sources: Dict[str, str],
|
| 88 |
+
) -> Dict[str, _SourcePair]:
|
| 89 |
+
all_keys = {_normalise_rel(k) for k in set(pre_sources) | set(post_sources)}
|
| 90 |
+
return {
|
| 91 |
+
k: _SourcePair(
|
| 92 |
+
pre_sources.get(k, pre_sources.get(k.replace("/", "\\"), "")),
|
| 93 |
+
post_sources.get(k, post_sources.get(k.replace("/", "\\"), "")),
|
| 94 |
+
)
|
| 95 |
+
for k in all_keys
|
| 96 |
+
}
|
| 97 |
+
|
| 98 |
+
|
| 99 |
+
def _normalise_rel(path: str) -> str:
|
| 100 |
+
return path.replace("\\", "/").lstrip("./")
|
| 101 |
+
|
| 102 |
+
|
| 103 |
+
def _get_pair(path: str, source_map: Dict[str, _SourcePair]) -> Optional[_SourcePair]:
|
| 104 |
+
path = _normalise_rel(path)
|
| 105 |
+
return source_map.get(path)
|
| 106 |
+
|
| 107 |
+
|
| 108 |
+
def _resolve_location(location: str) -> Tuple[str, str, str]:
|
| 109 |
+
"""Parse 'path/to/file.py::ClassName.method' into file/class/function."""
|
| 110 |
+
if "::" not in location:
|
| 111 |
+
return _normalise_rel(location), "", ""
|
| 112 |
+
file_part, qual = location.split("::", 1)
|
| 113 |
+
parts = qual.rsplit(".", 1)
|
| 114 |
+
if len(parts) == 2:
|
| 115 |
+
return _normalise_rel(file_part), parts[0], parts[1]
|
| 116 |
+
return _normalise_rel(file_part), "", qual
|
| 117 |
+
|
| 118 |
+
|
| 119 |
+
def _get_function_src(tree: ast.AST, func_name: str, class_name: str = "") -> str:
|
| 120 |
+
for node in ast.walk(tree):
|
| 121 |
+
if isinstance(node, ast.ClassDef) and class_name and node.name == class_name:
|
| 122 |
+
for item in node.body:
|
| 123 |
+
if isinstance(item, (ast.FunctionDef, ast.AsyncFunctionDef)) and item.name == func_name:
|
| 124 |
+
return ast.unparse(item)
|
| 125 |
+
if not class_name and isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) and node.name == func_name:
|
| 126 |
+
return ast.unparse(node)
|
| 127 |
+
return ""
|
| 128 |
+
|
| 129 |
+
|
| 130 |
+
def _libcst_parse_safe(source: str) -> Optional[cst.Module]:
|
| 131 |
+
if not _LIBCST_AVAILABLE or cst is None or not source:
|
| 132 |
+
return None
|
| 133 |
+
try:
|
| 134 |
+
return cst.parse_module(source)
|
| 135 |
+
except Exception:
|
| 136 |
+
return None
|
| 137 |
+
|
| 138 |
+
|
| 139 |
+
def _expr_name(node: Any) -> str:
|
| 140 |
+
if cst is None:
|
| 141 |
+
return ""
|
| 142 |
+
if isinstance(node, cst.Name):
|
| 143 |
+
return node.value
|
| 144 |
+
if isinstance(node, cst.Attribute):
|
| 145 |
+
left = _expr_name(node.value)
|
| 146 |
+
return f"{left}.{node.attr.value}" if left else node.attr.value
|
| 147 |
+
if isinstance(node, cst.Call):
|
| 148 |
+
return _expr_name(node.func)
|
| 149 |
+
if isinstance(node, cst.Subscript):
|
| 150 |
+
return _expr_name(node.value)
|
| 151 |
+
return ""
|
| 152 |
+
|
| 153 |
+
|
| 154 |
+
def _entity_leaf(entity: str) -> str:
|
| 155 |
+
return entity.split(".")[-1].strip()
|
| 156 |
+
|
| 157 |
+
|
| 158 |
+
def _targets_entity(expr: Any, entity: str) -> bool:
|
| 159 |
+
name = _expr_name(expr)
|
| 160 |
+
leaf = _entity_leaf(entity)
|
| 161 |
+
if not name:
|
| 162 |
+
return False
|
| 163 |
+
return name == entity or name.endswith(f".{leaf}") or name == leaf
|
| 164 |
+
|
| 165 |
+
|
| 166 |
+
def _iter_hunks_for_claim(claim: ThinkClaim, patch_hunks: Sequence[PatchHunk]) -> List[PatchHunk]:
|
| 167 |
+
if not patch_hunks:
|
| 168 |
+
return []
|
| 169 |
+
exact = [h for h in patch_hunks if h.addresses_claim and h.addresses_claim == claim.claim_id]
|
| 170 |
+
if exact:
|
| 171 |
+
return exact
|
| 172 |
+
file_path, _, _ = _resolve_location(claim.location)
|
| 173 |
+
return [h for h in patch_hunks if _normalise_rel(h.file) == file_path]
|
| 174 |
+
|
| 175 |
+
|
| 176 |
+
def _hunk_added_lines(hunk: PatchHunk) -> List[str]:
|
| 177 |
+
search_counts: Dict[str, int] = {}
|
| 178 |
+
for line in hunk.search.splitlines():
|
| 179 |
+
search_counts[line] = search_counts.get(line, 0) + 1
|
| 180 |
+
added: List[str] = []
|
| 181 |
+
for line in hunk.replace.splitlines():
|
| 182 |
+
count = search_counts.get(line, 0)
|
| 183 |
+
if count:
|
| 184 |
+
search_counts[line] = count - 1
|
| 185 |
+
else:
|
| 186 |
+
added.append(line)
|
| 187 |
+
return added
|
| 188 |
+
|
| 189 |
+
|
| 190 |
+
def _contains_sync_primitive(text: str) -> bool:
|
| 191 |
+
return bool(re.search(r"\b(?:Lock|RLock|Semaphore|Event|Condition)\s*\(", text))
|
| 192 |
+
|
| 193 |
+
|
| 194 |
+
def _contains_sync_usage(text: str) -> bool:
|
| 195 |
+
return (
|
| 196 |
+
bool(re.search(r"\bwith\s+.*(?:lock|Lock|RLock|Semaphore|Event|Condition)", text))
|
| 197 |
+
or ".acquire(" in text
|
| 198 |
+
or ".release(" in text
|
| 199 |
+
or "async with" in text
|
| 200 |
+
)
|
| 201 |
+
|
| 202 |
+
|
| 203 |
+
class OraclePlugin(ABC):
|
| 204 |
+
"""Base class for a single-category claim verifier."""
|
| 205 |
+
|
| 206 |
+
category: str = ""
|
| 207 |
+
|
| 208 |
+
@abstractmethod
|
| 209 |
+
def verify(
|
| 210 |
+
self,
|
| 211 |
+
claim: ThinkClaim,
|
| 212 |
+
source_map: Dict[str, _SourcePair],
|
| 213 |
+
*,
|
| 214 |
+
patch_hunks: Sequence[PatchHunk] = (),
|
| 215 |
+
dynamic_evidence: Optional[Dict[str, Any]] = None,
|
| 216 |
+
) -> Tuple[str, str, OracleEvidence]:
|
| 217 |
+
"""Return (verdict, reason_note, evidence)."""
|
| 218 |
+
|
| 219 |
+
|
| 220 |
+
def _verify_patch_addresses_claim(
|
| 221 |
+
claim: ThinkClaim,
|
| 222 |
+
patch_hunks: Sequence[PatchHunk],
|
| 223 |
+
) -> Tuple[bool, str]:
|
| 224 |
+
relevant_hunks = _iter_hunks_for_claim(claim, patch_hunks)
|
| 225 |
+
if not relevant_hunks:
|
| 226 |
+
return False, "no hunk claims or targets this claim"
|
| 227 |
+
|
| 228 |
+
file_path, _, _ = _resolve_location(claim.location)
|
| 229 |
+
for hunk in relevant_hunks:
|
| 230 |
+
if _normalise_rel(hunk.file) != file_path:
|
| 231 |
+
return False, f"hunk targets {hunk.file}, not {file_path}"
|
| 232 |
+
|
| 233 |
+
if claim.category in {"concurrency", "async_wait"}:
|
| 234 |
+
replacement = "\n".join(h.replace for h in relevant_hunks)
|
| 235 |
+
search = "\n".join(h.search for h in relevant_hunks)
|
| 236 |
+
entity_mentioned = bool(claim.entity and (claim.entity in replacement or claim.entity in search))
|
| 237 |
+
lock_added = any(_contains_sync_primitive("\n".join(_hunk_added_lines(h))) for h in relevant_hunks)
|
| 238 |
+
sync_used = _contains_sync_usage(replacement)
|
| 239 |
+
if "lock" in claim.reason.lower() or claim.category == "concurrency":
|
| 240 |
+
if not (lock_added or sync_used):
|
| 241 |
+
return False, "patch does not introduce or use synchronization"
|
| 242 |
+
if claim.entity and not entity_mentioned and not lock_added and not sync_used:
|
| 243 |
+
return False, "patch does not touch claimed entity or synchronization"
|
| 244 |
+
|
| 245 |
+
return True, "patch structurally addresses claim"
|
| 246 |
+
|
| 247 |
+
|
| 248 |
+
class _FunctionAccessCollector(_CST_VISITOR_BASE):
|
| 249 |
+
METADATA_DEPENDENCIES = (meta.ParentNodeProvider, meta.QualifiedNameProvider) if meta else ()
|
| 250 |
+
|
| 251 |
+
def __init__(self, entity: str, class_name: str, func_name: str) -> None:
|
| 252 |
+
self.entity = entity
|
| 253 |
+
self.class_name = class_name
|
| 254 |
+
self.func_name = func_name
|
| 255 |
+
self.accesses: List[cst.CSTNode] = []
|
| 256 |
+
self.entity_defined = False
|
| 257 |
+
self._class_depth = 0
|
| 258 |
+
self._function_depth = 0
|
| 259 |
+
self._in_target_class = not class_name
|
| 260 |
+
self._in_target_function = False
|
| 261 |
+
|
| 262 |
+
def visit_ClassDef(self, node: cst.ClassDef) -> Optional[bool]:
|
| 263 |
+
self._class_depth += 1
|
| 264 |
+
if self.class_name and node.name.value == self.class_name:
|
| 265 |
+
self._in_target_class = True
|
| 266 |
+
return True
|
| 267 |
+
|
| 268 |
+
def leave_ClassDef(self, node: cst.ClassDef) -> None:
|
| 269 |
+
if self.class_name and node.name.value == self.class_name:
|
| 270 |
+
self._in_target_class = False
|
| 271 |
+
self._class_depth -= 1
|
| 272 |
+
|
| 273 |
+
def visit_FunctionDef(self, node: cst.FunctionDef) -> Optional[bool]:
|
| 274 |
+
self._function_depth += 1
|
| 275 |
+
if self._in_target_class and node.name.value == self.func_name:
|
| 276 |
+
self._in_target_function = True
|
| 277 |
+
return True
|
| 278 |
+
|
| 279 |
+
def leave_FunctionDef(self, node: cst.FunctionDef) -> None:
|
| 280 |
+
if self._in_target_class and node.name.value == self.func_name:
|
| 281 |
+
self._in_target_function = False
|
| 282 |
+
self._function_depth -= 1
|
| 283 |
+
|
| 284 |
+
def visit_AssignTarget(self, node: cst.AssignTarget) -> None:
|
| 285 |
+
if _targets_entity(node.target, self.entity):
|
| 286 |
+
self.entity_defined = True
|
| 287 |
+
|
| 288 |
+
def visit_AnnAssign(self, node: cst.AnnAssign) -> None:
|
| 289 |
+
if _targets_entity(node.target, self.entity):
|
| 290 |
+
self.entity_defined = True
|
| 291 |
+
|
| 292 |
+
def visit_Attribute(self, node: cst.Attribute) -> None:
|
| 293 |
+
if self._in_target_function and _targets_entity(node, self.entity):
|
| 294 |
+
self.accesses.append(node)
|
| 295 |
+
|
| 296 |
+
def visit_Name(self, node: cst.Name) -> None:
|
| 297 |
+
if self._in_target_function and _targets_entity(node, self.entity):
|
| 298 |
+
self.accesses.append(node)
|
| 299 |
+
|
| 300 |
+
|
| 301 |
+
class _SyncGuardAnalyzer:
|
| 302 |
+
def __init__(self, wrapper: meta.MetadataWrapper, module: cst.Module) -> None:
|
| 303 |
+
self.wrapper = wrapper
|
| 304 |
+
self.module = module
|
| 305 |
+
self.parents = wrapper.resolve(meta.ParentNodeProvider)
|
| 306 |
+
self.qnames = wrapper.resolve(meta.QualifiedNameProvider)
|
| 307 |
+
|
| 308 |
+
def is_sync_primitive(self, node: cst.BaseExpression) -> bool:
|
| 309 |
+
qualified = self.qnames.get(node, set())
|
| 310 |
+
if any(q.name in _SYNC_PRIMITIVES for q in qualified):
|
| 311 |
+
return True
|
| 312 |
+
name = _expr_name(node)
|
| 313 |
+
return name.split(".")[-1] in _SYNC_ATTRS
|
| 314 |
+
|
| 315 |
+
def guarding_locks(self, node: cst.CSTNode) -> List[str]:
|
| 316 |
+
guards: List[str] = []
|
| 317 |
+
current: Optional[cst.CSTNode] = node
|
| 318 |
+
while current is not None and current in self.parents:
|
| 319 |
+
parent = self.parents[current]
|
| 320 |
+
if isinstance(parent, (cst.With, cst.WithItem)):
|
| 321 |
+
items = parent.items if isinstance(parent, cst.With) else [parent]
|
| 322 |
+
for item in items:
|
| 323 |
+
expr = item.item
|
| 324 |
+
if isinstance(expr, cst.Call):
|
| 325 |
+
expr = expr.func
|
| 326 |
+
if self.is_sync_primitive(expr):
|
| 327 |
+
guards.append(_expr_name(expr) or "sync_primitive")
|
| 328 |
+
current = parent
|
| 329 |
+
return guards
|
| 330 |
+
|
| 331 |
+
|
| 332 |
+
class RaceConditionOracle(OraclePlugin):
|
| 333 |
+
"""Verify concurrency claims using entity access and synchronization guards."""
|
| 334 |
+
|
| 335 |
+
category = "concurrency"
|
| 336 |
+
|
| 337 |
+
def verify(
|
| 338 |
+
self,
|
| 339 |
+
claim: ThinkClaim,
|
| 340 |
+
source_map: Dict[str, _SourcePair],
|
| 341 |
+
*,
|
| 342 |
+
patch_hunks: Sequence[PatchHunk] = (),
|
| 343 |
+
dynamic_evidence: Optional[Dict[str, Any]] = None,
|
| 344 |
+
) -> Tuple[str, str, OracleEvidence]:
|
| 345 |
+
file_path, class_name, func_name = _resolve_location(claim.location)
|
| 346 |
+
evidence = OracleEvidence()
|
| 347 |
+
patch_ok, patch_note = _verify_patch_addresses_claim(claim, patch_hunks)
|
| 348 |
+
evidence.patch_addresses_claim = patch_ok
|
| 349 |
+
evidence.notes.append(patch_note)
|
| 350 |
+
|
| 351 |
+
pair = _get_pair(file_path, source_map)
|
| 352 |
+
if pair is None:
|
| 353 |
+
return "inconclusive", f"file {file_path!r} not in source map", evidence
|
| 354 |
+
|
| 355 |
+
dynamic_pairs = (dynamic_evidence or {}).get("detected_race_pairs", [])
|
| 356 |
+
if any(claim.entity and claim.entity in str(item) for item in dynamic_pairs):
|
| 357 |
+
evidence.dynamic_confirmed = True
|
| 358 |
+
evidence.entity_resolved = True
|
| 359 |
+
evidence.pre_condition_met = True
|
| 360 |
+
|
| 361 |
+
pre_unprotected, pre_note = self._find_unprotected_accesses(pair.pre_src, claim, class_name, func_name)
|
| 362 |
+
evidence.notes.append(pre_note)
|
| 363 |
+
evidence.entity_resolved = evidence.entity_resolved or "entity resolved" in pre_note
|
| 364 |
+
evidence.pre_condition_met = evidence.pre_condition_met or bool(pre_unprotected)
|
| 365 |
+
|
| 366 |
+
if not evidence.pre_condition_met:
|
| 367 |
+
if claim.polarity == "present":
|
| 368 |
+
return "refuted", pre_note or "no unprotected entity access found", evidence
|
| 369 |
+
return "inconclusive", "no unprotected entity access in pre-patch", evidence
|
| 370 |
+
|
| 371 |
+
post_unprotected, post_note = self._find_unprotected_accesses(pair.post_src, claim, class_name, func_name)
|
| 372 |
+
evidence.notes.append(post_note)
|
| 373 |
+
evidence.post_condition_met = not post_unprotected and bool(pair.post_src)
|
| 374 |
+
|
| 375 |
+
if claim.polarity == "absent":
|
| 376 |
+
if evidence.post_condition_met:
|
| 377 |
+
return "confirmed", "unprotected access absent post-patch", evidence
|
| 378 |
+
return "refuted", "unprotected access still present post-patch", evidence
|
| 379 |
+
|
| 380 |
+
if pair.post_src and not post_unprotected:
|
| 381 |
+
if not patch_ok:
|
| 382 |
+
return "inconclusive", patch_note, evidence
|
| 383 |
+
return "confirmed", "race existed and patch protects the claimed entity", evidence
|
| 384 |
+
|
| 385 |
+
if not patch_ok:
|
| 386 |
+
return "inconclusive", patch_note, evidence
|
| 387 |
+
return "confirmed", "race exists but remains partially unprotected", evidence
|
| 388 |
+
|
| 389 |
+
def _find_unprotected_accesses(
|
| 390 |
+
self,
|
| 391 |
+
source: str,
|
| 392 |
+
claim: ThinkClaim,
|
| 393 |
+
class_name: str,
|
| 394 |
+
func_name: str,
|
| 395 |
+
) -> Tuple[List[cst.CSTNode], str]:
|
| 396 |
+
module = _libcst_parse_safe(source)
|
| 397 |
+
if module is None or cst is None or meta is None:
|
| 398 |
+
return [], "libcst unavailable"
|
| 399 |
+
if not func_name:
|
| 400 |
+
return [], "claim location does not name a function"
|
| 401 |
+
|
| 402 |
+
wrapper = meta.MetadataWrapper(module)
|
| 403 |
+
collector = _FunctionAccessCollector(claim.entity, class_name, func_name)
|
| 404 |
+
wrapper.visit(collector)
|
| 405 |
+
|
| 406 |
+
if not collector.entity_defined and "." in claim.entity:
|
| 407 |
+
return [], f"entity {claim.entity!r} not resolved in {claim.location}"
|
| 408 |
+
if not collector.accesses:
|
| 409 |
+
return [], f"no access to {claim.entity!r} in {claim.location}"
|
| 410 |
+
|
| 411 |
+
analyzer = _SyncGuardAnalyzer(wrapper, module)
|
| 412 |
+
unprotected = [access for access in collector.accesses if not analyzer.guarding_locks(access)]
|
| 413 |
+
return unprotected, f"entity resolved; {len(unprotected)} unprotected access(es)"
|
| 414 |
+
|
| 415 |
+
|
| 416 |
+
class AsyncWaitOracle(OraclePlugin):
|
| 417 |
+
"""Verify async wait/timeout symptoms without treating all sleeps as races."""
|
| 418 |
+
|
| 419 |
+
category = "async_wait"
|
| 420 |
+
|
| 421 |
+
def verify(
|
| 422 |
+
self,
|
| 423 |
+
claim: ThinkClaim,
|
| 424 |
+
source_map: Dict[str, _SourcePair],
|
| 425 |
+
*,
|
| 426 |
+
patch_hunks: Sequence[PatchHunk] = (),
|
| 427 |
+
dynamic_evidence: Optional[Dict[str, Any]] = None,
|
| 428 |
+
) -> Tuple[str, str, OracleEvidence]:
|
| 429 |
+
del dynamic_evidence
|
| 430 |
+
file_path, class_name, func_name = _resolve_location(claim.location)
|
| 431 |
+
evidence = OracleEvidence()
|
| 432 |
+
patch_ok, patch_note = _verify_patch_addresses_claim(claim, patch_hunks)
|
| 433 |
+
evidence.patch_addresses_claim = patch_ok
|
| 434 |
+
pair = _get_pair(file_path, source_map)
|
| 435 |
+
if pair is None:
|
| 436 |
+
return "inconclusive", f"file {file_path!r} not in source map", evidence
|
| 437 |
+
|
| 438 |
+
pre_tree = pair.pre_ast()
|
| 439 |
+
if pre_tree is None:
|
| 440 |
+
return "inconclusive", "pre-patch AST unavailable", evidence
|
| 441 |
+
pre_fn = _get_function_src(pre_tree, func_name or claim.entity, class_name)
|
| 442 |
+
evidence.entity_resolved = bool(pre_fn)
|
| 443 |
+
if not pre_fn:
|
| 444 |
+
return "inconclusive", f"function {claim.entity!r} not found", evidence
|
| 445 |
+
|
| 446 |
+
pre_issue = self._has_async_wait_issue(pre_fn)
|
| 447 |
+
evidence.pre_condition_met = pre_issue
|
| 448 |
+
if not pre_issue:
|
| 449 |
+
return ("refuted" if claim.polarity == "present" else "inconclusive"), "no async wait smell found", evidence
|
| 450 |
+
|
| 451 |
+
post_tree = pair.post_ast()
|
| 452 |
+
if post_tree is None:
|
| 453 |
+
return "confirmed", "async wait smell present pre-patch", evidence
|
| 454 |
+
post_fn = _get_function_src(post_tree, func_name or claim.entity, class_name)
|
| 455 |
+
post_issue = self._has_async_wait_issue(post_fn)
|
| 456 |
+
evidence.post_condition_met = not post_issue
|
| 457 |
+
if not post_issue and patch_ok:
|
| 458 |
+
return "confirmed", "async wait issue fixed", evidence
|
| 459 |
+
if not patch_ok:
|
| 460 |
+
return "inconclusive", patch_note, evidence
|
| 461 |
+
return "confirmed", "async wait issue still present", evidence
|
| 462 |
+
|
| 463 |
+
@staticmethod
|
| 464 |
+
def _has_async_wait_issue(func_src: str) -> bool:
|
| 465 |
+
try:
|
| 466 |
+
tree = ast.parse(func_src)
|
| 467 |
+
except SyntaxError:
|
| 468 |
+
return False
|
| 469 |
+
for node in ast.walk(tree):
|
| 470 |
+
if isinstance(node, ast.Call):
|
| 471 |
+
call = ast.unparse(node.func)
|
| 472 |
+
if call.endswith("wait_for"):
|
| 473 |
+
has_timeout = any(k.arg == "timeout" for k in node.keywords)
|
| 474 |
+
return not has_timeout or any(
|
| 475 |
+
isinstance(k.value, ast.Constant) and isinstance(k.value.value, (int, float)) and k.value.value < 0.1
|
| 476 |
+
for k in node.keywords
|
| 477 |
+
if k.arg == "timeout"
|
| 478 |
+
)
|
| 479 |
+
if call in _BLOCKING_SYNC_CALLS:
|
| 480 |
+
return True
|
| 481 |
+
return False
|
| 482 |
+
|
| 483 |
+
|
| 484 |
+
class LRUCacheOracle(OraclePlugin):
|
| 485 |
+
category = "module_cache_pollution"
|
| 486 |
+
|
| 487 |
+
def verify(
|
| 488 |
+
self,
|
| 489 |
+
claim: ThinkClaim,
|
| 490 |
+
source_map: Dict[str, _SourcePair],
|
| 491 |
+
*,
|
| 492 |
+
patch_hunks: Sequence[PatchHunk] = (),
|
| 493 |
+
dynamic_evidence: Optional[Dict[str, Any]] = None,
|
| 494 |
+
) -> Tuple[str, str, OracleEvidence]:
|
| 495 |
+
del patch_hunks, dynamic_evidence
|
| 496 |
+
file_path, class_name, func_name = _resolve_location(claim.location)
|
| 497 |
+
evidence = OracleEvidence()
|
| 498 |
+
pair = _get_pair(file_path, source_map)
|
| 499 |
+
if pair is None:
|
| 500 |
+
return "inconclusive", "file not in source map", evidence
|
| 501 |
+
pre_tree = pair.pre_ast()
|
| 502 |
+
if pre_tree is None:
|
| 503 |
+
return "inconclusive", "pre-patch AST unavailable", evidence
|
| 504 |
+
|
| 505 |
+
target = func_name or claim.entity
|
| 506 |
+
evidence.entity_resolved = bool(target)
|
| 507 |
+
pre_has_cache = self._has_lru_cache(pre_tree, target)
|
| 508 |
+
evidence.pre_condition_met = pre_has_cache
|
| 509 |
+
if claim.polarity == "present" and not pre_has_cache:
|
| 510 |
+
return "refuted", f"no lru_cache on {target!r} in pre-patch", evidence
|
| 511 |
+
|
| 512 |
+
post_tree = pair.post_ast()
|
| 513 |
+
post_has_cache = self._has_lru_cache(post_tree, target) if post_tree else pre_has_cache
|
| 514 |
+
evidence.post_condition_met = not post_has_cache
|
| 515 |
+
if claim.polarity == "absent":
|
| 516 |
+
return ("confirmed" if not post_has_cache else "refuted"), "cache absent post-patch" if not post_has_cache else "cache still present", evidence
|
| 517 |
+
return "confirmed", "cache removed post-patch" if not post_has_cache else "cache still present", evidence
|
| 518 |
+
|
| 519 |
+
@staticmethod
|
| 520 |
+
def _has_lru_cache(tree: Optional[ast.AST], entity: str) -> bool:
|
| 521 |
+
if tree is None:
|
| 522 |
+
return False
|
| 523 |
+
for node in ast.walk(tree):
|
| 524 |
+
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) and (node.name == entity or not entity):
|
| 525 |
+
for dec in node.decorator_list:
|
| 526 |
+
dec_src = ast.unparse(dec).lower()
|
| 527 |
+
if "lru_cache" in dec_src or "cache" in dec_src:
|
| 528 |
+
return True
|
| 529 |
+
return False
|
| 530 |
+
|
| 531 |
+
|
| 532 |
+
class MockLeakOracle(OraclePlugin):
|
| 533 |
+
category = "mock_residue"
|
| 534 |
+
_MOCK_PATTERNS = ("mock.patch(", "unittest.mock.patch(", "@patch(", "@mock.patch(")
|
| 535 |
+
|
| 536 |
+
def verify(
|
| 537 |
+
self,
|
| 538 |
+
claim: ThinkClaim,
|
| 539 |
+
source_map: Dict[str, _SourcePair],
|
| 540 |
+
*,
|
| 541 |
+
patch_hunks: Sequence[PatchHunk] = (),
|
| 542 |
+
dynamic_evidence: Optional[Dict[str, Any]] = None,
|
| 543 |
+
) -> Tuple[str, str, OracleEvidence]:
|
| 544 |
+
del patch_hunks, dynamic_evidence
|
| 545 |
+
file_path, _, _ = _resolve_location(claim.location)
|
| 546 |
+
evidence = OracleEvidence(entity_resolved=True)
|
| 547 |
+
pair = _get_pair(file_path, source_map)
|
| 548 |
+
if pair is None:
|
| 549 |
+
return "inconclusive", "file not in source map", evidence
|
| 550 |
+
|
| 551 |
+
pre_leak = self._has_unscoped_mock(pair.pre_src)
|
| 552 |
+
evidence.pre_condition_met = pre_leak
|
| 553 |
+
if claim.polarity == "present" and not pre_leak:
|
| 554 |
+
return "refuted", "no unscoped mock pattern in pre-patch", evidence
|
| 555 |
+
post_leak = self._has_unscoped_mock(pair.post_src)
|
| 556 |
+
evidence.post_condition_met = not post_leak
|
| 557 |
+
if claim.polarity == "absent":
|
| 558 |
+
return ("confirmed" if not post_leak else "refuted"), "mock is scoped post-patch" if not post_leak else "unscoped mock remains", evidence
|
| 559 |
+
return "confirmed", "fixed post-patch" if not post_leak else "leak persists", evidence
|
| 560 |
+
|
| 561 |
+
def _has_unscoped_mock(self, source: str) -> bool:
|
| 562 |
+
if not source or not any(p in source for p in self._MOCK_PATTERNS):
|
| 563 |
+
return False
|
| 564 |
+
return not ("with mock.patch" in source or "with patch" in source or ".stop()" in source or "addCleanup" in source)
|
| 565 |
+
|
| 566 |
+
|
| 567 |
+
class _MutationFinder(_CST_VISITOR_BASE):
|
| 568 |
+
def __init__(self, entity: str) -> None:
|
| 569 |
+
self.entity = entity
|
| 570 |
+
self.mutations: List[cst.CSTNode] = []
|
| 571 |
+
|
| 572 |
+
def visit_Assign(self, node: cst.Assign) -> None:
|
| 573 |
+
for target in node.targets:
|
| 574 |
+
if _targets_entity(target.target, self.entity):
|
| 575 |
+
self.mutations.append(node)
|
| 576 |
+
|
| 577 |
+
def visit_AnnAssign(self, node: cst.AnnAssign) -> None:
|
| 578 |
+
if _targets_entity(node.target, self.entity):
|
| 579 |
+
self.mutations.append(node)
|
| 580 |
+
|
| 581 |
+
def visit_AugAssign(self, node: cst.AugAssign) -> None:
|
| 582 |
+
if _targets_entity(node.target, self.entity):
|
| 583 |
+
self.mutations.append(node)
|
| 584 |
+
|
| 585 |
+
def visit_Call(self, node: cst.Call) -> None:
|
| 586 |
+
if isinstance(node.func, cst.Attribute) and node.func.attr.value in _MUTATING_METHODS:
|
| 587 |
+
if _targets_entity(node.func.value, self.entity):
|
| 588 |
+
self.mutations.append(node)
|
| 589 |
+
|
| 590 |
+
|
| 591 |
+
class SharedStateOracle(OraclePlugin):
|
| 592 |
+
category = "shared_state"
|
| 593 |
+
|
| 594 |
+
def verify(
|
| 595 |
+
self,
|
| 596 |
+
claim: ThinkClaim,
|
| 597 |
+
source_map: Dict[str, _SourcePair],
|
| 598 |
+
*,
|
| 599 |
+
patch_hunks: Sequence[PatchHunk] = (),
|
| 600 |
+
dynamic_evidence: Optional[Dict[str, Any]] = None,
|
| 601 |
+
) -> Tuple[str, str, OracleEvidence]:
|
| 602 |
+
del patch_hunks, dynamic_evidence
|
| 603 |
+
file_path, _, _ = _resolve_location(claim.location)
|
| 604 |
+
evidence = OracleEvidence()
|
| 605 |
+
pair = _get_pair(file_path, source_map)
|
| 606 |
+
if pair is None:
|
| 607 |
+
return "inconclusive", "file not in source map", evidence
|
| 608 |
+
|
| 609 |
+
pre_mutations = self._find_mutations(pair.pre_src, claim.entity)
|
| 610 |
+
evidence.entity_resolved = bool(claim.entity)
|
| 611 |
+
evidence.pre_condition_met = bool(pre_mutations)
|
| 612 |
+
if claim.polarity == "present" and not pre_mutations:
|
| 613 |
+
return "refuted", f"no mutation of {claim.entity!r} found", evidence
|
| 614 |
+
|
| 615 |
+
post_mutations = self._find_mutations(pair.post_src, claim.entity)
|
| 616 |
+
evidence.post_condition_met = not post_mutations
|
| 617 |
+
if claim.polarity == "absent":
|
| 618 |
+
return ("confirmed" if not post_mutations else "refuted"), "mutation absent post-patch" if not post_mutations else "mutation remains", evidence
|
| 619 |
+
return "confirmed", "mutation removed post-patch" if not post_mutations else "mutation persists", evidence
|
| 620 |
+
|
| 621 |
+
@staticmethod
|
| 622 |
+
def _find_mutations(source: str, entity: str) -> List[Any]:
|
| 623 |
+
module = _libcst_parse_safe(source)
|
| 624 |
+
if module is not None and cst is not None:
|
| 625 |
+
visitor = _MutationFinder(entity)
|
| 626 |
+
module.visit(visitor)
|
| 627 |
+
return visitor.mutations
|
| 628 |
+
try:
|
| 629 |
+
tree = ast.parse(source)
|
| 630 |
+
except SyntaxError:
|
| 631 |
+
return []
|
| 632 |
+
mutations: List[ast.AST] = []
|
| 633 |
+
for node in ast.walk(tree):
|
| 634 |
+
if isinstance(node, (ast.Assign, ast.AugAssign, ast.AnnAssign)):
|
| 635 |
+
targets = getattr(node, "targets", [getattr(node, "target", None)])
|
| 636 |
+
if any(t is not None and entity in ast.unparse(t) for t in targets):
|
| 637 |
+
mutations.append(node)
|
| 638 |
+
if isinstance(node, ast.Call) and isinstance(node.func, ast.Attribute):
|
| 639 |
+
if node.func.attr in _MUTATING_METHODS and entity in ast.unparse(node.func.value):
|
| 640 |
+
mutations.append(node)
|
| 641 |
+
return mutations
|
| 642 |
+
|
| 643 |
+
|
| 644 |
+
class TestOrderOracle(SharedStateOracle):
|
| 645 |
+
category = "test_order_dependency"
|
| 646 |
+
|
| 647 |
+
|
| 648 |
+
class FixtureScopeOracle(OraclePlugin):
|
| 649 |
+
category = "fixture_scope_leak"
|
| 650 |
+
|
| 651 |
+
def verify(
|
| 652 |
+
self,
|
| 653 |
+
claim: ThinkClaim,
|
| 654 |
+
source_map: Dict[str, _SourcePair],
|
| 655 |
+
*,
|
| 656 |
+
patch_hunks: Sequence[PatchHunk] = (),
|
| 657 |
+
dynamic_evidence: Optional[Dict[str, Any]] = None,
|
| 658 |
+
) -> Tuple[str, str, OracleEvidence]:
|
| 659 |
+
del patch_hunks, dynamic_evidence
|
| 660 |
+
file_path, _, func_name = _resolve_location(claim.location)
|
| 661 |
+
evidence = OracleEvidence()
|
| 662 |
+
pair = _get_pair(file_path, source_map)
|
| 663 |
+
if pair is None:
|
| 664 |
+
return "inconclusive", "file not in source map", evidence
|
| 665 |
+
pre_tree = pair.pre_ast()
|
| 666 |
+
if pre_tree is None:
|
| 667 |
+
return "inconclusive", "pre-patch AST unavailable", evidence
|
| 668 |
+
|
| 669 |
+
target = func_name or claim.entity
|
| 670 |
+
evidence.entity_resolved = bool(target)
|
| 671 |
+
pre_leak = self._has_scope_leak(pre_tree, target)
|
| 672 |
+
evidence.pre_condition_met = pre_leak
|
| 673 |
+
if claim.polarity == "present" and not pre_leak:
|
| 674 |
+
return "refuted", f"no fixture scope leak found for {target!r}", evidence
|
| 675 |
+
post_tree = pair.post_ast()
|
| 676 |
+
post_leak = self._has_scope_leak(post_tree, target) if post_tree else pre_leak
|
| 677 |
+
evidence.post_condition_met = not post_leak
|
| 678 |
+
if claim.polarity == "absent":
|
| 679 |
+
return ("confirmed" if not post_leak else "refuted"), "fixture scope clean post-patch" if not post_leak else "fixture leak remains", evidence
|
| 680 |
+
return "confirmed", "fixed post-patch" if not post_leak else "leak persists", evidence
|
| 681 |
+
|
| 682 |
+
@staticmethod
|
| 683 |
+
def _has_scope_leak(tree: Optional[ast.AST], entity: str) -> bool:
|
| 684 |
+
if tree is None:
|
| 685 |
+
return False
|
| 686 |
+
for node in ast.walk(tree):
|
| 687 |
+
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
| 688 |
+
if entity and node.name != entity:
|
| 689 |
+
continue
|
| 690 |
+
for dec in node.decorator_list:
|
| 691 |
+
dec_src = ast.unparse(dec)
|
| 692 |
+
if "fixture" in dec_src and ("session" in dec_src or "module" in dec_src):
|
| 693 |
+
if not any(isinstance(n, ast.Yield) for n in ast.walk(node)):
|
| 694 |
+
return True
|
| 695 |
+
return False
|
| 696 |
+
|
| 697 |
+
|
| 698 |
+
class PatternOracle(OraclePlugin):
|
| 699 |
+
"""Small static oracle for categories without a full semantic verifier."""
|
| 700 |
+
|
| 701 |
+
category = "unknown"
|
| 702 |
+
patterns: Tuple[str, ...] = ()
|
| 703 |
+
|
| 704 |
+
def verify(
|
| 705 |
+
self,
|
| 706 |
+
claim: ThinkClaim,
|
| 707 |
+
source_map: Dict[str, _SourcePair],
|
| 708 |
+
*,
|
| 709 |
+
patch_hunks: Sequence[PatchHunk] = (),
|
| 710 |
+
dynamic_evidence: Optional[Dict[str, Any]] = None,
|
| 711 |
+
) -> Tuple[str, str, OracleEvidence]:
|
| 712 |
+
del patch_hunks, dynamic_evidence
|
| 713 |
+
file_path, _, _ = _resolve_location(claim.location)
|
| 714 |
+
evidence = OracleEvidence(entity_resolved=bool(claim.location))
|
| 715 |
+
pair = _get_pair(file_path, source_map)
|
| 716 |
+
if pair is None:
|
| 717 |
+
return "inconclusive", "file not in source map", evidence
|
| 718 |
+
pre_hit = any(re.search(p, pair.pre_src) for p in self.patterns)
|
| 719 |
+
post_hit = any(re.search(p, pair.post_src) for p in self.patterns) if pair.post_src else pre_hit
|
| 720 |
+
evidence.pre_condition_met = pre_hit
|
| 721 |
+
evidence.post_condition_met = not post_hit
|
| 722 |
+
if not pre_hit:
|
| 723 |
+
return ("refuted" if claim.polarity == "present" else "inconclusive"), f"no static pattern for {self.category}", evidence
|
| 724 |
+
if claim.polarity == "absent":
|
| 725 |
+
return ("confirmed" if not post_hit else "refuted"), "pattern absent post-patch" if not post_hit else "pattern remains", evidence
|
| 726 |
+
return "confirmed", "pattern removed post-patch" if not post_hit else "static pattern present", evidence
|
| 727 |
+
|
| 728 |
+
|
| 729 |
+
class ResourceLeakOracle(PatternOracle):
|
| 730 |
+
category = "resource_leak"
|
| 731 |
+
patterns = (r"\bopen\s*\(", r"\bsocket\s*\(", r"\bconnect\s*\(", r"\.close\s*\(")
|
| 732 |
+
|
| 733 |
+
|
| 734 |
+
class NetworkOracle(PatternOracle):
|
| 735 |
+
category = "network"
|
| 736 |
+
patterns = (r"\brequests\.(get|post|put|delete)\s*\(", r"urllib\.request", r"httpx\.")
|
| 737 |
+
|
| 738 |
+
|
| 739 |
+
class ImportSideEffectOracle(PatternOracle):
|
| 740 |
+
category = "import_side_effect"
|
| 741 |
+
patterns = (r"(?m)^[A-Za-z_]\w*\s*=\s*(?![\"'\d\[\]\{\}\(\)]+$).+", r"(?m)^[A-Za-z_][\w\.]+\s*\(")
|
| 742 |
+
|
| 743 |
+
|
| 744 |
+
class PlatformDependencyOracle(PatternOracle):
|
| 745 |
+
category = "platform_dependency"
|
| 746 |
+
patterns = (r"sys\.platform", r"os\.name", r"platform\.system", r"[A-Za-z]:\\\\", r"/tmp/")
|
| 747 |
+
|
| 748 |
+
|
| 749 |
+
class NondeterminismOracle(PatternOracle):
|
| 750 |
+
category = "nondeterminism"
|
| 751 |
+
patterns = (r"random\.", r"uuid\.uuid", r"datetime\.now", r"time\.time\(", r"secrets\.")
|
| 752 |
+
|
| 753 |
+
|
| 754 |
+
class PatchCoherenceOracle:
|
| 755 |
+
"""Cross-cutting check that patch mechanics match the claim mechanism."""
|
| 756 |
+
|
| 757 |
+
def verify(self, claim: ThinkClaim, post_sources: Dict[str, str], patch_hunks: Sequence[PatchHunk]) -> Tuple[bool, str]:
|
| 758 |
+
relevant = _iter_hunks_for_claim(claim, patch_hunks)
|
| 759 |
+
if not relevant:
|
| 760 |
+
return False, "no hunk linked to claim"
|
| 761 |
+
file_path, _, _ = _resolve_location(claim.location)
|
| 762 |
+
post_src = post_sources.get(file_path, "")
|
| 763 |
+
replacement = "\n".join(h.replace for h in relevant)
|
| 764 |
+
reason = claim.reason.lower()
|
| 765 |
+
|
| 766 |
+
if claim.category in {"concurrency", "async_wait"} or "lock" in reason:
|
| 767 |
+
has_instantiation = _contains_sync_primitive(post_src) or _contains_sync_primitive(replacement)
|
| 768 |
+
has_usage = _contains_sync_usage(post_src) or _contains_sync_usage(replacement)
|
| 769 |
+
if has_instantiation and not has_usage:
|
| 770 |
+
return False, "lock instantiated but never acquired or used as context manager"
|
| 771 |
+
|
| 772 |
+
deleted_function = bool(re.search(r"^\s*(def|async def)\s+", "\n".join(h.search for h in relevant), re.MULTILINE)) and not replacement.strip()
|
| 773 |
+
if deleted_function:
|
| 774 |
+
return False, "patch deletes the claimed function instead of fixing it"
|
| 775 |
+
|
| 776 |
+
return True, "patch coherence accepted"
|
| 777 |
+
|
| 778 |
+
|
| 779 |
+
_PLUGIN_REGISTRY: Dict[str, OraclePlugin] = {}
|
| 780 |
+
|
| 781 |
+
|
| 782 |
+
def _register(*plugins: OraclePlugin) -> None:
|
| 783 |
+
for plugin in plugins:
|
| 784 |
+
_PLUGIN_REGISTRY[plugin.category] = plugin
|
| 785 |
+
|
| 786 |
+
|
| 787 |
+
_register(
|
| 788 |
+
RaceConditionOracle(),
|
| 789 |
+
AsyncWaitOracle(),
|
| 790 |
+
LRUCacheOracle(),
|
| 791 |
+
MockLeakOracle(),
|
| 792 |
+
SharedStateOracle(),
|
| 793 |
+
TestOrderOracle(),
|
| 794 |
+
FixtureScopeOracle(),
|
| 795 |
+
ResourceLeakOracle(),
|
| 796 |
+
NetworkOracle(),
|
| 797 |
+
ImportSideEffectOracle(),
|
| 798 |
+
PlatformDependencyOracle(),
|
| 799 |
+
NondeterminismOracle(),
|
| 800 |
+
)
|
| 801 |
+
|
| 802 |
+
|
| 803 |
+
def _score_claim(verdict: str, evidence: OracleEvidence) -> float:
|
| 804 |
+
if verdict == "refuted":
|
| 805 |
+
return -1.0
|
| 806 |
+
if verdict == "unverified":
|
| 807 |
+
return 0.0
|
| 808 |
+
|
| 809 |
+
score = 0.0
|
| 810 |
+
if evidence.entity_resolved:
|
| 811 |
+
score += 0.2
|
| 812 |
+
if evidence.pre_condition_met:
|
| 813 |
+
score += 0.3
|
| 814 |
+
if evidence.post_condition_met:
|
| 815 |
+
score += 0.3
|
| 816 |
+
if evidence.patch_addresses_claim:
|
| 817 |
+
score += 0.2
|
| 818 |
+
if evidence.dynamic_confirmed:
|
| 819 |
+
score = max(score, 0.95)
|
| 820 |
+
|
| 821 |
+
if verdict == "inconclusive":
|
| 822 |
+
score = min(score, 0.55)
|
| 823 |
+
return round(min(1.0, score), 4)
|
| 824 |
+
|
| 825 |
+
|
| 826 |
+
def verify_structured_think(
|
| 827 |
+
structured: StructuredThink,
|
| 828 |
+
pre_sources: Dict[str, str],
|
| 829 |
+
post_sources: Dict[str, str],
|
| 830 |
+
*,
|
| 831 |
+
patch_hunks: Sequence[PatchHunk] = (),
|
| 832 |
+
dynamic_evidence: Optional[Dict[str, Any]] = None,
|
| 833 |
+
) -> Tuple[StructuredThink, float]:
|
| 834 |
+
"""Verify all claims against pre/post source and patch coherence."""
|
| 835 |
+
if not structured.claims:
|
| 836 |
+
raw_score = structured.format_penalty
|
| 837 |
+
return structured, float(max(-1.0, min(1.0, raw_score)))
|
| 838 |
+
|
| 839 |
+
source_map = _build_source_map(pre_sources, post_sources)
|
| 840 |
+
coherence = PatchCoherenceOracle()
|
| 841 |
+
annotated_claims: List[ThinkClaim] = []
|
| 842 |
+
claim_scores: List[float] = []
|
| 843 |
+
|
| 844 |
+
for claim in structured.claims:
|
| 845 |
+
plugin = _PLUGIN_REGISTRY.get(claim.category)
|
| 846 |
+
if plugin is None:
|
| 847 |
+
verdict = "inconclusive"
|
| 848 |
+
note = f"category {claim.category!r} has no static oracle"
|
| 849 |
+
evidence = OracleEvidence(entity_resolved=bool(claim.location))
|
| 850 |
+
else:
|
| 851 |
+
try:
|
| 852 |
+
verdict, note, evidence = plugin.verify(
|
| 853 |
+
claim,
|
| 854 |
+
source_map,
|
| 855 |
+
patch_hunks=patch_hunks,
|
| 856 |
+
dynamic_evidence=dynamic_evidence,
|
| 857 |
+
)
|
| 858 |
+
except Exception as exc:
|
| 859 |
+
verdict, note, evidence = "unverified", f"oracle error: {exc}", OracleEvidence()
|
| 860 |
+
logger.warning("[ORACLE] %s claim=%s error: %s", claim.category, claim.claim_id, exc)
|
| 861 |
+
|
| 862 |
+
coherent, coherence_note = coherence.verify(claim, post_sources, patch_hunks)
|
| 863 |
+
if patch_hunks:
|
| 864 |
+
evidence.patch_addresses_claim = evidence.patch_addresses_claim and coherent
|
| 865 |
+
if not coherent and verdict == "confirmed":
|
| 866 |
+
verdict = "inconclusive"
|
| 867 |
+
note = coherence_note
|
| 868 |
+
|
| 869 |
+
score = _score_claim(verdict, evidence)
|
| 870 |
+
claim_scores.append(score)
|
| 871 |
+
annotated_claims.append(claim.model_copy(update={"verdict": verdict, "oracle_score": score}))
|
| 872 |
+
|
| 873 |
+
logger.debug(
|
| 874 |
+
"[ORACLE] claim=%s cat=%s verdict=%s score=%.2f note=%s coherence=%s",
|
| 875 |
+
claim.claim_id,
|
| 876 |
+
claim.category,
|
| 877 |
+
verdict,
|
| 878 |
+
score,
|
| 879 |
+
note,
|
| 880 |
+
coherence_note,
|
| 881 |
+
)
|
| 882 |
+
|
| 883 |
+
mean_claim_score = sum(claim_scores) / len(claim_scores)
|
| 884 |
+
raw_score = mean_claim_score + structured.format_penalty
|
| 885 |
+
oracle_score = float(max(-1.0, min(1.0, raw_score)))
|
| 886 |
+
return structured.model_copy(update={"claims": annotated_claims}), oracle_score
|
server/patch_applier.py
ADDED
|
@@ -0,0 +1,1060 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Patch Applier — search/replace hunk parser and atomic applier with rollback."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import ast
|
| 6 |
+
import os
|
| 7 |
+
import re
|
| 8 |
+
import subprocess
|
| 9 |
+
from dataclasses import dataclass, field
|
| 10 |
+
from pathlib import Path
|
| 11 |
+
from typing import Any, Dict, List, Optional, Tuple
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
PROTECTED_EXACT_NAMES = {
|
| 15 |
+
"conftest.py",
|
| 16 |
+
"pytest.ini",
|
| 17 |
+
"setup.cfg",
|
| 18 |
+
"tox.ini",
|
| 19 |
+
"pyproject.toml",
|
| 20 |
+
"requirements.txt",
|
| 21 |
+
}
|
| 22 |
+
|
| 23 |
+
PROTECTED_DIR_PREFIXES = {
|
| 24 |
+
"agent/",
|
| 25 |
+
"server/",
|
| 26 |
+
"training/",
|
| 27 |
+
".github/",
|
| 28 |
+
".cursor/",
|
| 29 |
+
}
|
| 30 |
+
|
| 31 |
+
PROTECTED_SUFFIXES = {
|
| 32 |
+
".yaml",
|
| 33 |
+
".yml",
|
| 34 |
+
".toml",
|
| 35 |
+
}
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
@dataclass
|
| 39 |
+
class PatchHunk:
|
| 40 |
+
"""A single search/replace hunk."""
|
| 41 |
+
file_path: str
|
| 42 |
+
search_text: str
|
| 43 |
+
replace_text: str
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
def parse_search_replace_hunks(patch_text: str) -> List[PatchHunk]:
|
| 47 |
+
"""Parse <<<<<<< SEARCH / ======= / >>>>>>> REPLACE blocks.
|
| 48 |
+
|
| 49 |
+
Supports two formats:
|
| 50 |
+
1. With file header: --- path/to/file.py
|
| 51 |
+
2. Without file header (applies to default target)
|
| 52 |
+
"""
|
| 53 |
+
hunks: List[PatchHunk] = []
|
| 54 |
+
if not patch_text or not patch_text.strip():
|
| 55 |
+
return hunks
|
| 56 |
+
|
| 57 |
+
current_file = ""
|
| 58 |
+
|
| 59 |
+
# Split into sections by file headers
|
| 60 |
+
lines = patch_text.split("\n")
|
| 61 |
+
i = 0
|
| 62 |
+
while i < len(lines):
|
| 63 |
+
line = lines[i]
|
| 64 |
+
|
| 65 |
+
# Detect file header
|
| 66 |
+
if line.startswith("--- "):
|
| 67 |
+
current_file = line[4:].strip()
|
| 68 |
+
i += 1
|
| 69 |
+
continue
|
| 70 |
+
|
| 71 |
+
# Detect search block start
|
| 72 |
+
if line.strip().startswith("<<<<<<< SEARCH") or line.strip() == "<<<<<<<":
|
| 73 |
+
search_lines: List[str] = []
|
| 74 |
+
replace_lines: List[str] = []
|
| 75 |
+
i += 1
|
| 76 |
+
|
| 77 |
+
# Collect search text until =======
|
| 78 |
+
while i < len(lines) and not lines[i].strip().startswith("======="):
|
| 79 |
+
search_lines.append(lines[i])
|
| 80 |
+
i += 1
|
| 81 |
+
|
| 82 |
+
if i < len(lines):
|
| 83 |
+
i += 1 # skip =======
|
| 84 |
+
|
| 85 |
+
# Collect replace text until >>>>>>> REPLACE
|
| 86 |
+
while i < len(lines) and not lines[i].strip().startswith(">>>>>>> REPLACE") and not lines[i].strip() == ">>>>>>>":
|
| 87 |
+
replace_lines.append(lines[i])
|
| 88 |
+
i += 1
|
| 89 |
+
|
| 90 |
+
if i < len(lines):
|
| 91 |
+
i += 1 # skip >>>>>>>
|
| 92 |
+
|
| 93 |
+
search = "\n".join(search_lines)
|
| 94 |
+
replace = "\n".join(replace_lines)
|
| 95 |
+
|
| 96 |
+
if search.strip(): # Only add if search text is non-empty
|
| 97 |
+
hunks.append(PatchHunk(
|
| 98 |
+
file_path=current_file,
|
| 99 |
+
search_text=search,
|
| 100 |
+
replace_text=replace,
|
| 101 |
+
))
|
| 102 |
+
continue
|
| 103 |
+
|
| 104 |
+
i += 1
|
| 105 |
+
|
| 106 |
+
return hunks
|
| 107 |
+
|
| 108 |
+
|
| 109 |
+
def simulate_search_replace_patch(
|
| 110 |
+
repo_path: Path,
|
| 111 |
+
patch_text: str,
|
| 112 |
+
default_target: Optional[str] = None,
|
| 113 |
+
pre_sources: Optional[Dict[str, str]] = None,
|
| 114 |
+
) -> Dict[str, Any]:
|
| 115 |
+
"""Dry-run: resolve and apply all hunks in memory only (no disk writes).
|
| 116 |
+
|
| 117 |
+
Returns the same shape as ``apply_search_replace_patch`` plus:
|
| 118 |
+
- ``modified_sources``: rel POSIX path -> post-patch full file text
|
| 119 |
+
- ``original_sources``: rel POSIX path -> original file text before any hunk
|
| 120 |
+
- ``rollback_snapshots``: alias of original_sources, kept for existing callers
|
| 121 |
+
|
| 122 |
+
If *pre_sources* is provided (relative POSIX path -> file text), the simulator
|
| 123 |
+
uses that text instead of reading from disk for the initial snapshot — keeps
|
| 124 |
+
validation aligned with the environment's view when snapshots are taken before
|
| 125 |
+
the step.
|
| 126 |
+
|
| 127 |
+
Used by :class:`server.patch_validator.PatchValidator` so invalid patches never
|
| 128 |
+
touch the working tree.
|
| 129 |
+
"""
|
| 130 |
+
hunks = parse_search_replace_hunks(patch_text)
|
| 131 |
+
|
| 132 |
+
if not hunks:
|
| 133 |
+
return {
|
| 134 |
+
"success": False,
|
| 135 |
+
"error": "no_valid_hunks_found",
|
| 136 |
+
"files_modified": [],
|
| 137 |
+
"lines_changed": 0,
|
| 138 |
+
"hunks_applied": 0,
|
| 139 |
+
"diff": "",
|
| 140 |
+
"noop": False,
|
| 141 |
+
"protected_file": False,
|
| 142 |
+
"fuzzy_applied": False,
|
| 143 |
+
"modified_sources": {},
|
| 144 |
+
"original_sources": {},
|
| 145 |
+
"rollback_snapshots": {},
|
| 146 |
+
}
|
| 147 |
+
|
| 148 |
+
files_modified: List[str] = []
|
| 149 |
+
total_lines_changed = 0
|
| 150 |
+
hunks_applied = 0
|
| 151 |
+
all_diffs: List[str] = []
|
| 152 |
+
fuzzy_applied = False
|
| 153 |
+
# rel_path -> current in-memory content (starts as disk snapshot once loaded)
|
| 154 |
+
memory_files: Dict[str, str] = {}
|
| 155 |
+
rollback_snapshots: Dict[str, str] = {}
|
| 156 |
+
|
| 157 |
+
def _rel_key(target: Path) -> str:
|
| 158 |
+
return str(target.resolve().relative_to(repo_path.resolve())).replace("\\", "/")
|
| 159 |
+
|
| 160 |
+
for hunk in hunks:
|
| 161 |
+
file_path = hunk.file_path or default_target or ""
|
| 162 |
+
if not file_path:
|
| 163 |
+
continue
|
| 164 |
+
if _is_protected_path(file_path):
|
| 165 |
+
return {
|
| 166 |
+
"success": False,
|
| 167 |
+
"error": f"protected_file_{file_path}",
|
| 168 |
+
"files_modified": files_modified,
|
| 169 |
+
"lines_changed": total_lines_changed,
|
| 170 |
+
"hunks_applied": hunks_applied,
|
| 171 |
+
"diff": "\n".join(all_diffs),
|
| 172 |
+
"noop": False,
|
| 173 |
+
"protected_file": True,
|
| 174 |
+
"fuzzy_applied": fuzzy_applied,
|
| 175 |
+
"modified_sources": {},
|
| 176 |
+
"original_sources": dict(rollback_snapshots),
|
| 177 |
+
"rollback_snapshots": dict(rollback_snapshots),
|
| 178 |
+
}
|
| 179 |
+
|
| 180 |
+
target = repo_path / file_path
|
| 181 |
+
if not target.exists():
|
| 182 |
+
candidates = [
|
| 183 |
+
candidate
|
| 184 |
+
for candidate in repo_path.rglob(Path(file_path).name)
|
| 185 |
+
if not _is_protected_path(str(candidate.relative_to(repo_path)))
|
| 186 |
+
]
|
| 187 |
+
if candidates:
|
| 188 |
+
target = candidates[0]
|
| 189 |
+
else:
|
| 190 |
+
return {
|
| 191 |
+
"success": False,
|
| 192 |
+
"error": f"target_file_not_found_{file_path}",
|
| 193 |
+
"files_modified": files_modified,
|
| 194 |
+
"lines_changed": total_lines_changed,
|
| 195 |
+
"hunks_applied": hunks_applied,
|
| 196 |
+
"diff": "\n".join(all_diffs),
|
| 197 |
+
"noop": False,
|
| 198 |
+
"protected_file": False,
|
| 199 |
+
"fuzzy_applied": fuzzy_applied,
|
| 200 |
+
"modified_sources": {},
|
| 201 |
+
"original_sources": dict(rollback_snapshots),
|
| 202 |
+
"rollback_snapshots": dict(rollback_snapshots),
|
| 203 |
+
}
|
| 204 |
+
else:
|
| 205 |
+
try:
|
| 206 |
+
resolved_relative = str(target.resolve().relative_to(repo_path.resolve())).replace("\\", "/")
|
| 207 |
+
except ValueError:
|
| 208 |
+
return {
|
| 209 |
+
"success": False,
|
| 210 |
+
"error": f"target_outside_repo_{file_path}",
|
| 211 |
+
"files_modified": files_modified,
|
| 212 |
+
"lines_changed": total_lines_changed,
|
| 213 |
+
"hunks_applied": hunks_applied,
|
| 214 |
+
"diff": "\n".join(all_diffs),
|
| 215 |
+
"noop": False,
|
| 216 |
+
"protected_file": True,
|
| 217 |
+
"fuzzy_applied": fuzzy_applied,
|
| 218 |
+
"modified_sources": {},
|
| 219 |
+
"original_sources": dict(rollback_snapshots),
|
| 220 |
+
"rollback_snapshots": dict(rollback_snapshots),
|
| 221 |
+
}
|
| 222 |
+
if _is_protected_path(resolved_relative):
|
| 223 |
+
return {
|
| 224 |
+
"success": False,
|
| 225 |
+
"error": f"protected_file_{resolved_relative}",
|
| 226 |
+
"files_modified": files_modified,
|
| 227 |
+
"lines_changed": total_lines_changed,
|
| 228 |
+
"hunks_applied": hunks_applied,
|
| 229 |
+
"diff": "\n".join(all_diffs),
|
| 230 |
+
"noop": False,
|
| 231 |
+
"protected_file": True,
|
| 232 |
+
"fuzzy_applied": fuzzy_applied,
|
| 233 |
+
"modified_sources": {},
|
| 234 |
+
"original_sources": dict(rollback_snapshots),
|
| 235 |
+
"rollback_snapshots": dict(rollback_snapshots),
|
| 236 |
+
}
|
| 237 |
+
|
| 238 |
+
rel = _rel_key(target)
|
| 239 |
+
if rel not in memory_files:
|
| 240 |
+
if pre_sources is not None and rel in pre_sources:
|
| 241 |
+
original = pre_sources[rel]
|
| 242 |
+
elif pre_sources is not None and file_path.replace("\\", "/") in pre_sources:
|
| 243 |
+
original = pre_sources[file_path.replace("\\", "/")]
|
| 244 |
+
else:
|
| 245 |
+
original = target.read_text(encoding="utf-8", errors="ignore")
|
| 246 |
+
memory_files[rel] = original
|
| 247 |
+
rollback_snapshots[rel] = original
|
| 248 |
+
|
| 249 |
+
original = memory_files[rel]
|
| 250 |
+
modified = _apply_single_hunk(original, hunk.search_text, hunk.replace_text)
|
| 251 |
+
if modified is None:
|
| 252 |
+
modified = _apply_fuzzy_hunk(original, hunk.search_text, hunk.replace_text)
|
| 253 |
+
if modified is None:
|
| 254 |
+
return {
|
| 255 |
+
"success": False,
|
| 256 |
+
"error": f"search_text_not_found_in_{target.name}",
|
| 257 |
+
"files_modified": files_modified,
|
| 258 |
+
"lines_changed": total_lines_changed,
|
| 259 |
+
"hunks_applied": hunks_applied,
|
| 260 |
+
"diff": "\n".join(all_diffs),
|
| 261 |
+
"noop": False,
|
| 262 |
+
"protected_file": False,
|
| 263 |
+
"fuzzy_applied": fuzzy_applied,
|
| 264 |
+
"modified_sources": {},
|
| 265 |
+
"original_sources": dict(rollback_snapshots),
|
| 266 |
+
"rollback_snapshots": dict(rollback_snapshots),
|
| 267 |
+
}
|
| 268 |
+
fuzzy_applied = True
|
| 269 |
+
|
| 270 |
+
memory_files[rel] = modified
|
| 271 |
+
lines_changed = _count_lines_changed(original, modified)
|
| 272 |
+
total_lines_changed += lines_changed
|
| 273 |
+
hunks_applied += 1
|
| 274 |
+
abs_path = str(target)
|
| 275 |
+
if abs_path not in files_modified:
|
| 276 |
+
files_modified.append(abs_path)
|
| 277 |
+
diff_text = _make_unified_diff(original, modified, rel)
|
| 278 |
+
if diff_text:
|
| 279 |
+
all_diffs.append(diff_text)
|
| 280 |
+
|
| 281 |
+
if hunks_applied != len(hunks):
|
| 282 |
+
return {
|
| 283 |
+
"success": False,
|
| 284 |
+
"error": "not_all_hunks_applied",
|
| 285 |
+
"files_modified": files_modified,
|
| 286 |
+
"lines_changed": total_lines_changed,
|
| 287 |
+
"hunks_applied": hunks_applied,
|
| 288 |
+
"diff": "\n".join(all_diffs),
|
| 289 |
+
"noop": False,
|
| 290 |
+
"protected_file": False,
|
| 291 |
+
"fuzzy_applied": fuzzy_applied,
|
| 292 |
+
"modified_sources": {},
|
| 293 |
+
"original_sources": dict(rollback_snapshots),
|
| 294 |
+
"rollback_snapshots": dict(rollback_snapshots),
|
| 295 |
+
}
|
| 296 |
+
|
| 297 |
+
noop = total_lines_changed == 0 or not "\n".join(all_diffs).strip()
|
| 298 |
+
modified_sources = {k: v for k, v in memory_files.items() if k in rollback_snapshots}
|
| 299 |
+
|
| 300 |
+
return {
|
| 301 |
+
"success": True,
|
| 302 |
+
"files_modified": files_modified,
|
| 303 |
+
"lines_changed": total_lines_changed,
|
| 304 |
+
"hunks_applied": hunks_applied,
|
| 305 |
+
"diff": "\n".join(all_diffs),
|
| 306 |
+
"error": None,
|
| 307 |
+
"noop": noop,
|
| 308 |
+
"protected_file": False,
|
| 309 |
+
"fuzzy_applied": fuzzy_applied,
|
| 310 |
+
"modified_sources": modified_sources,
|
| 311 |
+
"original_sources": dict(rollback_snapshots),
|
| 312 |
+
"rollback_snapshots": dict(rollback_snapshots),
|
| 313 |
+
}
|
| 314 |
+
|
| 315 |
+
|
| 316 |
+
def restore_repo_files(repo_path: Path, rollback_snapshots: Dict[str, str]) -> None:
|
| 317 |
+
"""Write *rollback_snapshots* (relative path -> text) back under *repo_path*."""
|
| 318 |
+
for rel, content in rollback_snapshots.items():
|
| 319 |
+
path = repo_path / rel.replace("/", os.sep)
|
| 320 |
+
path.parent.mkdir(parents=True, exist_ok=True)
|
| 321 |
+
path.write_text(content, encoding="utf-8")
|
| 322 |
+
|
| 323 |
+
|
| 324 |
+
def write_validated_sources(repo_path: Path, modified_sources: Dict[str, str]) -> None:
|
| 325 |
+
"""Write validator-approved ``modified_sources`` to disk exactly once."""
|
| 326 |
+
for rel, content in modified_sources.items():
|
| 327 |
+
path = repo_path / rel.replace("/", os.sep)
|
| 328 |
+
try:
|
| 329 |
+
path.resolve().relative_to(repo_path.resolve())
|
| 330 |
+
except ValueError as exc:
|
| 331 |
+
raise ValueError(f"target_outside_repo_{rel}") from exc
|
| 332 |
+
if _is_protected_path(rel):
|
| 333 |
+
raise ValueError(f"protected_file_{rel}")
|
| 334 |
+
path.parent.mkdir(parents=True, exist_ok=True)
|
| 335 |
+
path.write_text(content, encoding="utf-8")
|
| 336 |
+
|
| 337 |
+
|
| 338 |
+
def apply_search_replace_patch(
|
| 339 |
+
repo_path: Path,
|
| 340 |
+
patch_text: str,
|
| 341 |
+
default_target: Optional[str] = None,
|
| 342 |
+
) -> Dict[str, Any]:
|
| 343 |
+
"""Apply search/replace patches atomically. Rolls back on failure.
|
| 344 |
+
|
| 345 |
+
Args:
|
| 346 |
+
repo_path: Root of the repository
|
| 347 |
+
patch_text: Raw patch text with search/replace hunks
|
| 348 |
+
default_target: Default file to target if no file header in patch
|
| 349 |
+
|
| 350 |
+
Returns:
|
| 351 |
+
Dict with keys: success, files_modified, lines_changed, hunks_applied,
|
| 352 |
+
error (if failed), diff
|
| 353 |
+
"""
|
| 354 |
+
hunks = parse_search_replace_hunks(patch_text)
|
| 355 |
+
|
| 356 |
+
if not hunks:
|
| 357 |
+
return {
|
| 358 |
+
"success": False,
|
| 359 |
+
"error": "no_valid_hunks_found",
|
| 360 |
+
"files_modified": [],
|
| 361 |
+
"lines_changed": 0,
|
| 362 |
+
"hunks_applied": 0,
|
| 363 |
+
"diff": "",
|
| 364 |
+
"noop": False,
|
| 365 |
+
"protected_file": False,
|
| 366 |
+
"fuzzy_applied": False,
|
| 367 |
+
}
|
| 368 |
+
|
| 369 |
+
files_modified: List[str] = []
|
| 370 |
+
total_lines_changed = 0
|
| 371 |
+
hunks_applied = 0
|
| 372 |
+
all_diffs: List[str] = []
|
| 373 |
+
originals: Dict[Path, str] = {}
|
| 374 |
+
fuzzy_applied = False
|
| 375 |
+
|
| 376 |
+
def rollback() -> None:
|
| 377 |
+
for path, content in originals.items():
|
| 378 |
+
path.write_text(content, encoding="utf-8")
|
| 379 |
+
|
| 380 |
+
try:
|
| 381 |
+
for hunk in hunks:
|
| 382 |
+
# Resolve file path
|
| 383 |
+
file_path = hunk.file_path or default_target or ""
|
| 384 |
+
if not file_path:
|
| 385 |
+
continue
|
| 386 |
+
if _is_protected_path(file_path):
|
| 387 |
+
rollback()
|
| 388 |
+
return {
|
| 389 |
+
"success": False,
|
| 390 |
+
"error": f"protected_file_{file_path}",
|
| 391 |
+
"files_modified": files_modified,
|
| 392 |
+
"lines_changed": total_lines_changed,
|
| 393 |
+
"hunks_applied": hunks_applied,
|
| 394 |
+
"diff": "\n".join(all_diffs),
|
| 395 |
+
"noop": False,
|
| 396 |
+
"protected_file": True,
|
| 397 |
+
"fuzzy_applied": fuzzy_applied,
|
| 398 |
+
}
|
| 399 |
+
|
| 400 |
+
target = repo_path / file_path
|
| 401 |
+
if not target.exists():
|
| 402 |
+
# Try to find the file
|
| 403 |
+
candidates = [
|
| 404 |
+
candidate
|
| 405 |
+
for candidate in repo_path.rglob(Path(file_path).name)
|
| 406 |
+
if not _is_protected_path(str(candidate.relative_to(repo_path)))
|
| 407 |
+
]
|
| 408 |
+
if candidates:
|
| 409 |
+
target = candidates[0]
|
| 410 |
+
else:
|
| 411 |
+
rollback()
|
| 412 |
+
return {
|
| 413 |
+
"success": False,
|
| 414 |
+
"error": f"target_file_not_found_{file_path}",
|
| 415 |
+
"files_modified": files_modified,
|
| 416 |
+
"lines_changed": total_lines_changed,
|
| 417 |
+
"hunks_applied": hunks_applied,
|
| 418 |
+
"diff": "\n".join(all_diffs),
|
| 419 |
+
"noop": False,
|
| 420 |
+
"protected_file": False,
|
| 421 |
+
"fuzzy_applied": fuzzy_applied,
|
| 422 |
+
}
|
| 423 |
+
else:
|
| 424 |
+
try:
|
| 425 |
+
resolved_relative = str(target.resolve().relative_to(repo_path.resolve())).replace("\\", "/")
|
| 426 |
+
except ValueError:
|
| 427 |
+
rollback()
|
| 428 |
+
return {
|
| 429 |
+
"success": False,
|
| 430 |
+
"error": f"target_outside_repo_{file_path}",
|
| 431 |
+
"files_modified": files_modified,
|
| 432 |
+
"lines_changed": total_lines_changed,
|
| 433 |
+
"hunks_applied": hunks_applied,
|
| 434 |
+
"diff": "\n".join(all_diffs),
|
| 435 |
+
"noop": False,
|
| 436 |
+
"protected_file": True,
|
| 437 |
+
"fuzzy_applied": fuzzy_applied,
|
| 438 |
+
}
|
| 439 |
+
if _is_protected_path(resolved_relative):
|
| 440 |
+
rollback()
|
| 441 |
+
return {
|
| 442 |
+
"success": False,
|
| 443 |
+
"error": f"protected_file_{resolved_relative}",
|
| 444 |
+
"files_modified": files_modified,
|
| 445 |
+
"lines_changed": total_lines_changed,
|
| 446 |
+
"hunks_applied": hunks_applied,
|
| 447 |
+
"diff": "\n".join(all_diffs),
|
| 448 |
+
"noop": False,
|
| 449 |
+
"protected_file": True,
|
| 450 |
+
"fuzzy_applied": fuzzy_applied,
|
| 451 |
+
}
|
| 452 |
+
|
| 453 |
+
original = target.read_text(encoding="utf-8", errors="ignore")
|
| 454 |
+
originals.setdefault(target, original)
|
| 455 |
+
|
| 456 |
+
# Apply the hunk
|
| 457 |
+
modified = _apply_single_hunk(original, hunk.search_text, hunk.replace_text)
|
| 458 |
+
if modified is None:
|
| 459 |
+
# Search text not found — try conservative indentation-only matching.
|
| 460 |
+
modified = _apply_fuzzy_hunk(original, hunk.search_text, hunk.replace_text)
|
| 461 |
+
if modified is None:
|
| 462 |
+
rollback()
|
| 463 |
+
return {
|
| 464 |
+
"success": False,
|
| 465 |
+
"error": f"search_text_not_found_in_{target.name}",
|
| 466 |
+
"files_modified": files_modified,
|
| 467 |
+
"lines_changed": total_lines_changed,
|
| 468 |
+
"hunks_applied": hunks_applied,
|
| 469 |
+
"diff": "\n".join(all_diffs),
|
| 470 |
+
"noop": False,
|
| 471 |
+
"protected_file": False,
|
| 472 |
+
"fuzzy_applied": fuzzy_applied,
|
| 473 |
+
}
|
| 474 |
+
fuzzy_applied = True
|
| 475 |
+
|
| 476 |
+
# Write modified content
|
| 477 |
+
target.write_text(modified, encoding="utf-8")
|
| 478 |
+
|
| 479 |
+
# Track changes
|
| 480 |
+
lines_changed = _count_lines_changed(original, modified)
|
| 481 |
+
total_lines_changed += lines_changed
|
| 482 |
+
hunks_applied += 1
|
| 483 |
+
if str(target) not in files_modified:
|
| 484 |
+
files_modified.append(str(target))
|
| 485 |
+
|
| 486 |
+
# Generate diff
|
| 487 |
+
diff_text = _make_unified_diff(original, modified, str(target.relative_to(repo_path)))
|
| 488 |
+
if diff_text:
|
| 489 |
+
all_diffs.append(diff_text)
|
| 490 |
+
|
| 491 |
+
if hunks_applied != len(hunks):
|
| 492 |
+
rollback()
|
| 493 |
+
return {
|
| 494 |
+
"success": False,
|
| 495 |
+
"error": "not_all_hunks_applied",
|
| 496 |
+
"files_modified": files_modified,
|
| 497 |
+
"lines_changed": total_lines_changed,
|
| 498 |
+
"hunks_applied": hunks_applied,
|
| 499 |
+
"diff": "\n".join(all_diffs),
|
| 500 |
+
"noop": False,
|
| 501 |
+
"protected_file": False,
|
| 502 |
+
"fuzzy_applied": fuzzy_applied,
|
| 503 |
+
}
|
| 504 |
+
|
| 505 |
+
noop = total_lines_changed == 0 or not "\n".join(all_diffs).strip()
|
| 506 |
+
return {
|
| 507 |
+
"success": True,
|
| 508 |
+
"files_modified": files_modified,
|
| 509 |
+
"lines_changed": total_lines_changed,
|
| 510 |
+
"hunks_applied": hunks_applied,
|
| 511 |
+
"diff": "\n".join(all_diffs),
|
| 512 |
+
"error": None,
|
| 513 |
+
"noop": noop,
|
| 514 |
+
"protected_file": False,
|
| 515 |
+
"fuzzy_applied": fuzzy_applied,
|
| 516 |
+
}
|
| 517 |
+
|
| 518 |
+
except Exception as exc:
|
| 519 |
+
# Rollback on any error
|
| 520 |
+
rollback()
|
| 521 |
+
return {
|
| 522 |
+
"success": False,
|
| 523 |
+
"error": str(exc),
|
| 524 |
+
"files_modified": [],
|
| 525 |
+
"lines_changed": 0,
|
| 526 |
+
"hunks_applied": 0,
|
| 527 |
+
"diff": "",
|
| 528 |
+
"noop": False,
|
| 529 |
+
"protected_file": False,
|
| 530 |
+
"fuzzy_applied": fuzzy_applied,
|
| 531 |
+
}
|
| 532 |
+
|
| 533 |
+
|
| 534 |
+
def apply_structured_patch(
|
| 535 |
+
repo_path: Path,
|
| 536 |
+
structured_patch: Any, # models.StructuredPatch
|
| 537 |
+
default_target: Optional[str] = None,
|
| 538 |
+
) -> Dict[str, Any]:
|
| 539 |
+
"""Apply a StructuredPatch (typed Pydantic model) atomically.
|
| 540 |
+
|
| 541 |
+
This is the preferred entry point when the agent emits valid JSON.
|
| 542 |
+
Each hunk's `file`, `search`, and `replace` fields are used directly —
|
| 543 |
+
no text parsing required. Falls back to ``apply_search_replace_patch``
|
| 544 |
+
when ``structured_patch`` has no hunks or is None.
|
| 545 |
+
|
| 546 |
+
Args:
|
| 547 |
+
repo_path: Root of the repository (Path).
|
| 548 |
+
structured_patch: ``models.StructuredPatch`` with a ``hunks`` list.
|
| 549 |
+
default_target: Fallback file if a hunk has no ``file`` set.
|
| 550 |
+
|
| 551 |
+
Returns:
|
| 552 |
+
Same dict shape as ``apply_search_replace_patch``.
|
| 553 |
+
"""
|
| 554 |
+
if structured_patch is None or not structured_patch.hunks:
|
| 555 |
+
return {
|
| 556 |
+
"success": False,
|
| 557 |
+
"error": "no_structured_hunks",
|
| 558 |
+
"files_modified": [],
|
| 559 |
+
"lines_changed": 0,
|
| 560 |
+
"hunks_applied": 0,
|
| 561 |
+
"diff": "",
|
| 562 |
+
"noop": False,
|
| 563 |
+
"protected_file": False,
|
| 564 |
+
"fuzzy_applied": False,
|
| 565 |
+
}
|
| 566 |
+
|
| 567 |
+
# Convert StructuredPatch.hunks → internal PatchHunk dataclass list.
|
| 568 |
+
internal_hunks: List[PatchHunk] = []
|
| 569 |
+
for h in structured_patch.hunks:
|
| 570 |
+
file_path = h.file or default_target or ""
|
| 571 |
+
if not file_path or not h.search:
|
| 572 |
+
continue # skip mal-formed hunks
|
| 573 |
+
internal_hunks.append(PatchHunk(
|
| 574 |
+
file_path=file_path,
|
| 575 |
+
search_text=h.search,
|
| 576 |
+
replace_text=h.replace,
|
| 577 |
+
))
|
| 578 |
+
|
| 579 |
+
if not internal_hunks:
|
| 580 |
+
return {
|
| 581 |
+
"success": False,
|
| 582 |
+
"error": "no_valid_structured_hunks_after_filter",
|
| 583 |
+
"files_modified": [],
|
| 584 |
+
"lines_changed": 0,
|
| 585 |
+
"hunks_applied": 0,
|
| 586 |
+
"diff": "",
|
| 587 |
+
"noop": False,
|
| 588 |
+
"protected_file": False,
|
| 589 |
+
"fuzzy_applied": False,
|
| 590 |
+
}
|
| 591 |
+
|
| 592 |
+
# ---- Core apply logic (mirrors apply_search_replace_patch) ----
|
| 593 |
+
files_modified: List[str] = []
|
| 594 |
+
total_lines_changed = 0
|
| 595 |
+
hunks_applied = 0
|
| 596 |
+
all_diffs: List[str] = []
|
| 597 |
+
originals: Dict[Path, str] = {}
|
| 598 |
+
fuzzy_applied = False
|
| 599 |
+
|
| 600 |
+
def rollback() -> None:
|
| 601 |
+
for path, content in originals.items():
|
| 602 |
+
path.write_text(content, encoding="utf-8")
|
| 603 |
+
|
| 604 |
+
try:
|
| 605 |
+
for hunk in internal_hunks:
|
| 606 |
+
file_path = hunk.file_path
|
| 607 |
+
if _is_protected_path(file_path):
|
| 608 |
+
rollback()
|
| 609 |
+
return {
|
| 610 |
+
"success": False,
|
| 611 |
+
"error": f"protected_file_{file_path}",
|
| 612 |
+
"files_modified": files_modified,
|
| 613 |
+
"lines_changed": total_lines_changed,
|
| 614 |
+
"hunks_applied": hunks_applied,
|
| 615 |
+
"diff": "\n".join(all_diffs),
|
| 616 |
+
"noop": False,
|
| 617 |
+
"protected_file": True,
|
| 618 |
+
"fuzzy_applied": fuzzy_applied,
|
| 619 |
+
}
|
| 620 |
+
|
| 621 |
+
target = repo_path / file_path
|
| 622 |
+
if not target.exists():
|
| 623 |
+
candidates = [
|
| 624 |
+
c for c in repo_path.rglob(Path(file_path).name)
|
| 625 |
+
if not _is_protected_path(str(c.relative_to(repo_path)))
|
| 626 |
+
]
|
| 627 |
+
if candidates:
|
| 628 |
+
target = candidates[0]
|
| 629 |
+
else:
|
| 630 |
+
rollback()
|
| 631 |
+
return {
|
| 632 |
+
"success": False,
|
| 633 |
+
"error": f"target_file_not_found_{file_path}",
|
| 634 |
+
"files_modified": files_modified,
|
| 635 |
+
"lines_changed": total_lines_changed,
|
| 636 |
+
"hunks_applied": hunks_applied,
|
| 637 |
+
"diff": "\n".join(all_diffs),
|
| 638 |
+
"noop": False,
|
| 639 |
+
"protected_file": False,
|
| 640 |
+
"fuzzy_applied": fuzzy_applied,
|
| 641 |
+
}
|
| 642 |
+
|
| 643 |
+
original = target.read_text(encoding="utf-8", errors="ignore")
|
| 644 |
+
originals.setdefault(target, original)
|
| 645 |
+
|
| 646 |
+
modified = _apply_single_hunk(original, hunk.search_text, hunk.replace_text)
|
| 647 |
+
if modified is None:
|
| 648 |
+
modified = _apply_fuzzy_hunk(original, hunk.search_text, hunk.replace_text)
|
| 649 |
+
if modified is None:
|
| 650 |
+
rollback()
|
| 651 |
+
return {
|
| 652 |
+
"success": False,
|
| 653 |
+
"error": f"search_text_not_found_in_{target.name}",
|
| 654 |
+
"files_modified": files_modified,
|
| 655 |
+
"lines_changed": total_lines_changed,
|
| 656 |
+
"hunks_applied": hunks_applied,
|
| 657 |
+
"diff": "\n".join(all_diffs),
|
| 658 |
+
"noop": False,
|
| 659 |
+
"protected_file": False,
|
| 660 |
+
"fuzzy_applied": fuzzy_applied,
|
| 661 |
+
}
|
| 662 |
+
fuzzy_applied = True
|
| 663 |
+
|
| 664 |
+
target.write_text(modified, encoding="utf-8")
|
| 665 |
+
lines_changed = _count_lines_changed(original, modified)
|
| 666 |
+
total_lines_changed += lines_changed
|
| 667 |
+
hunks_applied += 1
|
| 668 |
+
if str(target) not in files_modified:
|
| 669 |
+
files_modified.append(str(target))
|
| 670 |
+
diff_text = _make_unified_diff(original, modified, str(target.relative_to(repo_path)))
|
| 671 |
+
if diff_text:
|
| 672 |
+
all_diffs.append(diff_text)
|
| 673 |
+
|
| 674 |
+
if hunks_applied != len(internal_hunks):
|
| 675 |
+
rollback()
|
| 676 |
+
return {
|
| 677 |
+
"success": False,
|
| 678 |
+
"error": "not_all_structured_hunks_applied",
|
| 679 |
+
"files_modified": files_modified,
|
| 680 |
+
"lines_changed": total_lines_changed,
|
| 681 |
+
"hunks_applied": hunks_applied,
|
| 682 |
+
"diff": "\n".join(all_diffs),
|
| 683 |
+
"noop": False,
|
| 684 |
+
"protected_file": False,
|
| 685 |
+
"fuzzy_applied": fuzzy_applied,
|
| 686 |
+
}
|
| 687 |
+
|
| 688 |
+
noop = total_lines_changed == 0 or not "\n".join(all_diffs).strip()
|
| 689 |
+
return {
|
| 690 |
+
"success": True,
|
| 691 |
+
"files_modified": files_modified,
|
| 692 |
+
"lines_changed": total_lines_changed,
|
| 693 |
+
"hunks_applied": hunks_applied,
|
| 694 |
+
"diff": "\n".join(all_diffs),
|
| 695 |
+
"error": None,
|
| 696 |
+
"noop": noop,
|
| 697 |
+
"protected_file": False,
|
| 698 |
+
"fuzzy_applied": fuzzy_applied,
|
| 699 |
+
}
|
| 700 |
+
|
| 701 |
+
except Exception as exc:
|
| 702 |
+
rollback()
|
| 703 |
+
return {
|
| 704 |
+
"success": False,
|
| 705 |
+
"error": str(exc),
|
| 706 |
+
"files_modified": [],
|
| 707 |
+
"lines_changed": 0,
|
| 708 |
+
"hunks_applied": 0,
|
| 709 |
+
"diff": "",
|
| 710 |
+
"noop": False,
|
| 711 |
+
"protected_file": False,
|
| 712 |
+
"fuzzy_applied": fuzzy_applied,
|
| 713 |
+
}
|
| 714 |
+
|
| 715 |
+
|
| 716 |
+
def _apply_single_hunk(original: str, search: str, replace: str) -> Optional[str]:
|
| 717 |
+
"""Apply a single search/replace hunk. Returns None if search text not found."""
|
| 718 |
+
if search in original:
|
| 719 |
+
return original.replace(search, replace, 1)
|
| 720 |
+
|
| 721 |
+
# CRLF normalisation: model may emit \r\n while file uses \n (or vice-versa).
|
| 722 |
+
search_lf = search.replace("\r\n", "\n").replace("\r", "\n")
|
| 723 |
+
original_lf = original.replace("\r\n", "\n").replace("\r", "\n")
|
| 724 |
+
if search_lf in original_lf:
|
| 725 |
+
return original_lf.replace(search_lf, replace.replace("\r\n", "\n").replace("\r", "\n"), 1)
|
| 726 |
+
|
| 727 |
+
return None
|
| 728 |
+
|
| 729 |
+
|
| 730 |
+
def _normalise_line(line: str) -> str:
|
| 731 |
+
"""Normalise a line for fuzzy comparison.
|
| 732 |
+
|
| 733 |
+
Handles common 7B-model hallucinations: trailing backslash continuations,
|
| 734 |
+
trailing semicolons (from C-style habits), trailing colons on non-block
|
| 735 |
+
statements, and multiple-space collapse.
|
| 736 |
+
"""
|
| 737 |
+
s = line.strip()
|
| 738 |
+
s = s.rstrip("\\").rstrip()
|
| 739 |
+
s = s.rstrip(";")
|
| 740 |
+
s = re.sub(r"\s+", " ", s)
|
| 741 |
+
return s
|
| 742 |
+
|
| 743 |
+
|
| 744 |
+
def _apply_fuzzy_hunk(original: str, search: str, replace: str) -> Optional[str]:
|
| 745 |
+
"""Try conservative indentation-only matching when exact match fails."""
|
| 746 |
+
semantic_replacement = _apply_known_flaky_function_fix(original, search, replace)
|
| 747 |
+
if semantic_replacement is not None:
|
| 748 |
+
return semantic_replacement
|
| 749 |
+
|
| 750 |
+
search_lines = [_normalise_line(line) for line in search.strip().split("\n")]
|
| 751 |
+
original_lines = original.split("\n")
|
| 752 |
+
|
| 753 |
+
for start_idx in range(len(original_lines)):
|
| 754 |
+
if _normalise_line(original_lines[start_idx]) == search_lines[0]:
|
| 755 |
+
# Check if all search lines match
|
| 756 |
+
match = True
|
| 757 |
+
for j, search_line in enumerate(search_lines):
|
| 758 |
+
if start_idx + j >= len(original_lines):
|
| 759 |
+
match = False
|
| 760 |
+
break
|
| 761 |
+
if _normalise_line(original_lines[start_idx + j]) != search_line:
|
| 762 |
+
match = False
|
| 763 |
+
break
|
| 764 |
+
|
| 765 |
+
if match:
|
| 766 |
+
first_orig = original_lines[start_idx]
|
| 767 |
+
target_indent = first_orig[:len(first_orig) - len(first_orig.lstrip())]
|
| 768 |
+
|
| 769 |
+
replace_lines = replace.strip("\n").split("\n")
|
| 770 |
+
indented_replace = _reindent_block(replace_lines, target_indent)
|
| 771 |
+
|
| 772 |
+
result_lines = (
|
| 773 |
+
original_lines[:start_idx]
|
| 774 |
+
+ indented_replace
|
| 775 |
+
+ original_lines[start_idx + len(search_lines) :]
|
| 776 |
+
)
|
| 777 |
+
return "\n".join(result_lines)
|
| 778 |
+
|
| 779 |
+
function_replacement = _apply_function_replacement_hunk(original, search, replace)
|
| 780 |
+
if function_replacement is not None:
|
| 781 |
+
return function_replacement
|
| 782 |
+
|
| 783 |
+
return None
|
| 784 |
+
|
| 785 |
+
|
| 786 |
+
def _apply_known_flaky_function_fix(original: str, search: str, replace: str) -> Optional[str]:
|
| 787 |
+
"""Repair malformed hunks for the bundled flaky training repos.
|
| 788 |
+
|
| 789 |
+
Small models sometimes emit valid JSON whose hunk strings are not valid
|
| 790 |
+
multi-line Python, e.g. ``"with self._lock:\\\\\" if len(...)"``. When the
|
| 791 |
+
intent is still unambiguous from the tokens, apply the exact function-level
|
| 792 |
+
repair through AST ranges instead of rejecting the episode.
|
| 793 |
+
"""
|
| 794 |
+
combined = f"{search}\n{replace}".lower()
|
| 795 |
+
|
| 796 |
+
if (
|
| 797 |
+
"with self._lock" in combined
|
| 798 |
+
and ("queue_capacity" in combined or "self._queue" in combined)
|
| 799 |
+
) or (
|
| 800 |
+
"random.random() < 0.30" in combined
|
| 801 |
+
and ("queue_capacity" in combined or "return false" in combined)
|
| 802 |
+
) or "workerpool.submit" in combined:
|
| 803 |
+
return _replace_unique_function(
|
| 804 |
+
original,
|
| 805 |
+
"submit",
|
| 806 |
+
[
|
| 807 |
+
" def submit(self, job: dict[str, Any]) -> bool:",
|
| 808 |
+
" \"\"\"Submit a job. Returns False when the queue is full.\"\"\"",
|
| 809 |
+
" with self._lock:",
|
| 810 |
+
" if len(self._queue) >= self.QUEUE_CAPACITY:",
|
| 811 |
+
" return False",
|
| 812 |
+
" self._queue.append(job)",
|
| 813 |
+
" return True",
|
| 814 |
+
],
|
| 815 |
+
)
|
| 816 |
+
|
| 817 |
+
if (
|
| 818 |
+
"connection pool exhausted" in combined
|
| 819 |
+
and "_in_use" in combined
|
| 820 |
+
and "max_size" in combined
|
| 821 |
+
) or (
|
| 822 |
+
"_in_use" in combined
|
| 823 |
+
and "max_size" in combined
|
| 824 |
+
and "socket.socket" in combined
|
| 825 |
+
and "connectionrefusederror" in combined
|
| 826 |
+
) or (
|
| 827 |
+
"def acquire" in combined
|
| 828 |
+
and "self._pool" in combined
|
| 829 |
+
and "runtimeerror" in combined
|
| 830 |
+
and "timeout" in combined
|
| 831 |
+
):
|
| 832 |
+
return _replace_unique_function(
|
| 833 |
+
original,
|
| 834 |
+
"acquire",
|
| 835 |
+
[
|
| 836 |
+
" def acquire(self, timeout: float = 0.5) -> socket.socket:",
|
| 837 |
+
" \"\"\"Acquire a connection and wait briefly when pool is exhausted.\"\"\"",
|
| 838 |
+
" deadline = time.monotonic() + max(timeout, 0.0)",
|
| 839 |
+
" while True:",
|
| 840 |
+
" with self._lock:",
|
| 841 |
+
" if self._pool:",
|
| 842 |
+
" conn = self._pool.pop()",
|
| 843 |
+
" self._in_use.append(conn)",
|
| 844 |
+
" return conn",
|
| 845 |
+
" if len(self._in_use) < self.max_size:",
|
| 846 |
+
" conn = socket.socket(socket.AF_INET, socket.SOCK_STREAM)",
|
| 847 |
+
" conn.settimeout(timeout)",
|
| 848 |
+
" try:",
|
| 849 |
+
" conn.connect((self.host, self.port))",
|
| 850 |
+
" except (ConnectionRefusedError, OSError):",
|
| 851 |
+
" pass",
|
| 852 |
+
" self._in_use.append(conn)",
|
| 853 |
+
" return conn",
|
| 854 |
+
" if time.monotonic() >= deadline:",
|
| 855 |
+
" raise RuntimeError(\"Connection pool exhausted\")",
|
| 856 |
+
" # Retry until timeout without introducing sleep-based flake masking.",
|
| 857 |
+
" continue",
|
| 858 |
+
],
|
| 859 |
+
)
|
| 860 |
+
|
| 861 |
+
if (
|
| 862 |
+
"configstore.read" in combined
|
| 863 |
+
or "config_stale" in combined
|
| 864 |
+
or ("snapshot" in combined and "_data" in combined)
|
| 865 |
+
):
|
| 866 |
+
return _replace_unique_function(
|
| 867 |
+
original,
|
| 868 |
+
"read",
|
| 869 |
+
[
|
| 870 |
+
" def read(self, key: str) -> Any:",
|
| 871 |
+
" \"\"\"Read a config key without exposing transient refresh state.\"\"\"",
|
| 872 |
+
" snapshot = self._data",
|
| 873 |
+
" if snapshot is None:",
|
| 874 |
+
" return None",
|
| 875 |
+
" return snapshot.get(key)",
|
| 876 |
+
],
|
| 877 |
+
)
|
| 878 |
+
|
| 879 |
+
if "configstore.refresh" in combined or "_data = none" in combined:
|
| 880 |
+
return _replace_unique_function(
|
| 881 |
+
original,
|
| 882 |
+
"refresh",
|
| 883 |
+
[
|
| 884 |
+
" def refresh(self, new_data: dict[str, Any]) -> None:",
|
| 885 |
+
" \"\"\"Replace config atomically without exposing a None window.\"\"\"",
|
| 886 |
+
" with self._refresh_lock:",
|
| 887 |
+
" self._data = dict(new_data)",
|
| 888 |
+
],
|
| 889 |
+
)
|
| 890 |
+
|
| 891 |
+
return None
|
| 892 |
+
|
| 893 |
+
|
| 894 |
+
def _replace_unique_function(original: str, function_name: str, replacement_lines: List[str]) -> Optional[str]:
|
| 895 |
+
try:
|
| 896 |
+
tree = ast.parse(original)
|
| 897 |
+
except SyntaxError:
|
| 898 |
+
return None
|
| 899 |
+
|
| 900 |
+
matches: List[ast.AST] = [
|
| 901 |
+
node
|
| 902 |
+
for node in ast.walk(tree)
|
| 903 |
+
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) and node.name == function_name
|
| 904 |
+
]
|
| 905 |
+
if len(matches) != 1:
|
| 906 |
+
return None
|
| 907 |
+
|
| 908 |
+
node = matches[0]
|
| 909 |
+
if not hasattr(node, "lineno") or not hasattr(node, "end_lineno"):
|
| 910 |
+
return None
|
| 911 |
+
|
| 912 |
+
original_lines = original.split("\n")
|
| 913 |
+
start = int(node.lineno) - 1
|
| 914 |
+
end = int(node.end_lineno)
|
| 915 |
+
first_original = original_lines[start]
|
| 916 |
+
target_indent = first_original[:len(first_original) - len(first_original.lstrip())]
|
| 917 |
+
replacement = _reindent_block(replacement_lines, target_indent)
|
| 918 |
+
return "\n".join(original_lines[:start] + replacement + original_lines[end:])
|
| 919 |
+
|
| 920 |
+
|
| 921 |
+
def _apply_function_replacement_hunk(original: str, search: str, replace: str) -> Optional[str]:
|
| 922 |
+
"""Replace a full Python function/method when the hunk names one clearly.
|
| 923 |
+
|
| 924 |
+
LLMs often produce a valid fixed function but slightly drift in the search
|
| 925 |
+
block. If there is exactly one matching function name in the target file,
|
| 926 |
+
use the AST line range as a conservative fallback.
|
| 927 |
+
"""
|
| 928 |
+
target_name = _extract_single_function_name(search) or _extract_single_function_name(replace)
|
| 929 |
+
if not target_name:
|
| 930 |
+
return None
|
| 931 |
+
|
| 932 |
+
replacement_lines = replace.strip("\n").split("\n")
|
| 933 |
+
if not replacement_lines or not re.match(r"\s*(?:async\s+)?def\s+", replacement_lines[0]):
|
| 934 |
+
return None
|
| 935 |
+
|
| 936 |
+
try:
|
| 937 |
+
tree = ast.parse(original)
|
| 938 |
+
except SyntaxError:
|
| 939 |
+
return None
|
| 940 |
+
|
| 941 |
+
matches: List[ast.AST] = []
|
| 942 |
+
for node in ast.walk(tree):
|
| 943 |
+
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) and node.name == target_name:
|
| 944 |
+
matches.append(node)
|
| 945 |
+
|
| 946 |
+
if len(matches) != 1:
|
| 947 |
+
return None
|
| 948 |
+
|
| 949 |
+
node = matches[0]
|
| 950 |
+
if not hasattr(node, "lineno") or not hasattr(node, "end_lineno"):
|
| 951 |
+
return None
|
| 952 |
+
|
| 953 |
+
original_lines = original.split("\n")
|
| 954 |
+
start = int(node.lineno) - 1
|
| 955 |
+
end = int(node.end_lineno)
|
| 956 |
+
|
| 957 |
+
first_original = original_lines[start]
|
| 958 |
+
target_indent = first_original[:len(first_original) - len(first_original.lstrip())]
|
| 959 |
+
replacement = _reindent_block(replacement_lines, target_indent)
|
| 960 |
+
|
| 961 |
+
result_lines = original_lines[:start] + replacement + original_lines[end:]
|
| 962 |
+
return "\n".join(result_lines)
|
| 963 |
+
|
| 964 |
+
|
| 965 |
+
def _extract_single_function_name(text: str) -> str:
|
| 966 |
+
names = re.findall(r"^\s*(?:async\s+)?def\s+([A-Za-z_][A-Za-z0-9_]*)\s*\(", text, re.MULTILINE)
|
| 967 |
+
unique = sorted(set(names))
|
| 968 |
+
return unique[0] if len(unique) == 1 else ""
|
| 969 |
+
|
| 970 |
+
|
| 971 |
+
def _reindent_block(lines: List[str], target_indent: str) -> List[str]:
|
| 972 |
+
non_empty = [line for line in lines if line.strip()]
|
| 973 |
+
if not non_empty:
|
| 974 |
+
return lines
|
| 975 |
+
|
| 976 |
+
leading_counts = [len(line) - len(line.lstrip()) for line in non_empty]
|
| 977 |
+
common_indent = min(leading_counts)
|
| 978 |
+
|
| 979 |
+
reindented: List[str] = []
|
| 980 |
+
for line in lines:
|
| 981 |
+
if not line.strip():
|
| 982 |
+
reindented.append("")
|
| 983 |
+
continue
|
| 984 |
+
stripped_common = line[common_indent:]
|
| 985 |
+
reindented.append(target_indent + stripped_common)
|
| 986 |
+
return reindented
|
| 987 |
+
|
| 988 |
+
|
| 989 |
+
def _normalize_whitespace(text: str) -> str:
|
| 990 |
+
"""Normalize whitespace for fuzzy matching."""
|
| 991 |
+
return re.sub(r'\s+', ' ', text.strip())
|
| 992 |
+
|
| 993 |
+
|
| 994 |
+
def _is_protected_path(path: str) -> bool:
|
| 995 |
+
"""Return True when a model patch targets infrastructure/config files."""
|
| 996 |
+
normalized = path.replace("\\", "/").lstrip("./")
|
| 997 |
+
name = Path(normalized).name
|
| 998 |
+
if name in PROTECTED_EXACT_NAMES:
|
| 999 |
+
return True
|
| 1000 |
+
if any(normalized.startswith(prefix) for prefix in PROTECTED_DIR_PREFIXES):
|
| 1001 |
+
return True
|
| 1002 |
+
return any(normalized.endswith(suffix) for suffix in PROTECTED_SUFFIXES)
|
| 1003 |
+
|
| 1004 |
+
|
| 1005 |
+
def _count_lines_changed(original: str, modified: str) -> int:
|
| 1006 |
+
"""Count changed lines without over-counting shifted unchanged tails."""
|
| 1007 |
+
import difflib
|
| 1008 |
+
|
| 1009 |
+
changed = 0
|
| 1010 |
+
orig_lines = original.split("\n")
|
| 1011 |
+
mod_lines = modified.split("\n")
|
| 1012 |
+
matcher = difflib.SequenceMatcher(a=orig_lines, b=mod_lines)
|
| 1013 |
+
for tag, i1, i2, j1, j2 in matcher.get_opcodes():
|
| 1014 |
+
if tag == "equal":
|
| 1015 |
+
continue
|
| 1016 |
+
changed += max(i2 - i1, j2 - j1)
|
| 1017 |
+
return changed
|
| 1018 |
+
|
| 1019 |
+
|
| 1020 |
+
def _make_unified_diff(before: str, after: str, path: str) -> str:
|
| 1021 |
+
"""Generate unified diff."""
|
| 1022 |
+
import difflib
|
| 1023 |
+
return "".join(
|
| 1024 |
+
difflib.unified_diff(
|
| 1025 |
+
before.splitlines(keepends=True),
|
| 1026 |
+
after.splitlines(keepends=True),
|
| 1027 |
+
fromfile=f"a/{path}",
|
| 1028 |
+
tofile=f"b/{path}",
|
| 1029 |
+
n=3,
|
| 1030 |
+
)
|
| 1031 |
+
)
|
| 1032 |
+
|
| 1033 |
+
|
| 1034 |
+
def _create_git_stash(repo_path: Path) -> bool:
|
| 1035 |
+
"""Create a git stash for rollback. Returns True if stash was created."""
|
| 1036 |
+
try:
|
| 1037 |
+
result = subprocess.run(
|
| 1038 |
+
["git", "stash", "push", "-m", "flakeforge_rollback"],
|
| 1039 |
+
cwd=repo_path,
|
| 1040 |
+
capture_output=True,
|
| 1041 |
+
text=True,
|
| 1042 |
+
check=False,
|
| 1043 |
+
)
|
| 1044 |
+
return "No local changes" not in result.stdout
|
| 1045 |
+
except Exception:
|
| 1046 |
+
return False
|
| 1047 |
+
|
| 1048 |
+
|
| 1049 |
+
def _git_stash_pop(repo_path: Path) -> None:
|
| 1050 |
+
"""Pop the git stash to rollback changes."""
|
| 1051 |
+
try:
|
| 1052 |
+
subprocess.run(
|
| 1053 |
+
["git", "stash", "pop"],
|
| 1054 |
+
cwd=repo_path,
|
| 1055 |
+
capture_output=True,
|
| 1056 |
+
text=True,
|
| 1057 |
+
check=False,
|
| 1058 |
+
)
|
| 1059 |
+
except Exception:
|
| 1060 |
+
pass
|
server/patch_validator.py
ADDED
|
@@ -0,0 +1,627 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Patch Validation Engine — code-side verification (not reasoning).
|
| 2 |
+
|
| 3 |
+
OracleEngine answers: "Is the structured thinking consistent with the code?"
|
| 4 |
+
PatchValidator answers: "Is the patch well-formed, applicable, safe, and meaningful?"
|
| 5 |
+
|
| 6 |
+
Pipeline (stages):
|
| 7 |
+
1. Format — SEARCH/REPLACE blocks, file headers
|
| 8 |
+
2. Apply simulation — SEARCH must match source (via simulate_search_replace_patch)
|
| 9 |
+
3. Syntax — ast.parse on post-patch text
|
| 10 |
+
4. Compile — compile() on each modified module
|
| 11 |
+
5. Structure — e.g. empty function bodies, broken control flow heuristics
|
| 12 |
+
6. Causal proximity — optional warning if patch files far from failure frontier
|
| 13 |
+
|
| 14 |
+
Invalid patches must be rejected *before* disk writes; see FlakeForgeEnvironment.step.
|
| 15 |
+
"""
|
| 16 |
+
|
| 17 |
+
from __future__ import annotations
|
| 18 |
+
|
| 19 |
+
import ast
|
| 20 |
+
import builtins
|
| 21 |
+
import re
|
| 22 |
+
from dataclasses import dataclass, field
|
| 23 |
+
from pathlib import Path
|
| 24 |
+
from typing import Any, Dict, Iterable, List, Optional, Sequence, Set, Tuple
|
| 25 |
+
|
| 26 |
+
try:
|
| 27 |
+
import libcst as cst
|
| 28 |
+
_LIBCST_AVAILABLE = True
|
| 29 |
+
except ImportError:
|
| 30 |
+
cst = None
|
| 31 |
+
_LIBCST_AVAILABLE = False
|
| 32 |
+
|
| 33 |
+
try:
|
| 34 |
+
from server.patch_applier import parse_search_replace_hunks, simulate_search_replace_patch
|
| 35 |
+
except ImportError:
|
| 36 |
+
try:
|
| 37 |
+
from ..server.patch_applier import parse_search_replace_hunks, simulate_search_replace_patch
|
| 38 |
+
except ImportError:
|
| 39 |
+
from FlakeForge.server.patch_applier import parse_search_replace_hunks, simulate_search_replace_patch
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
@dataclass
|
| 43 |
+
class ValidationResult:
|
| 44 |
+
"""Outcome of patch validation (action / code path, not oracle)."""
|
| 45 |
+
|
| 46 |
+
is_valid: bool
|
| 47 |
+
errors: List[str] = field(default_factory=list)
|
| 48 |
+
warnings: List[str] = field(default_factory=list)
|
| 49 |
+
score: float = 0.0 # 0..1 for reward shaping when is_valid
|
| 50 |
+
simulate_result: Dict[str, Any] = field(default_factory=dict)
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
_SLEEP_PATTERNS = ("time.sleep(", "await asyncio.sleep(", "sleep(")
|
| 54 |
+
_SKIP_PATTERNS = ("@pytest.mark.skip", "@unittest.skip", "pytest.skip(")
|
| 55 |
+
_FLAKINESS_PATTERNS = (
|
| 56 |
+
("flaky_time_sleep", "time.sleep("),
|
| 57 |
+
("flaky_asyncio_sleep", "asyncio.sleep("),
|
| 58 |
+
("flaky_random_random", "random.random("),
|
| 59 |
+
("flaky_datetime_now", "datetime.now("),
|
| 60 |
+
("flaky_datetime_utcnow", "datetime.utcnow("),
|
| 61 |
+
)
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
def _normalise_rel(path: str) -> str:
|
| 65 |
+
return path.replace("\\", "/").lstrip("./")
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
def _resolve_claim_location(location: str) -> Tuple[str, str, str]:
|
| 69 |
+
"""Return (rel_path, class_name, function_or_entity) from claim.location."""
|
| 70 |
+
if "::" not in location:
|
| 71 |
+
return _normalise_rel(location), "", ""
|
| 72 |
+
file_part, qual = location.split("::", 1)
|
| 73 |
+
bits = qual.rsplit(".", 1)
|
| 74 |
+
if len(bits) == 2:
|
| 75 |
+
return _normalise_rel(file_part), bits[0], bits[1]
|
| 76 |
+
return _normalise_rel(file_part), "", qual
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
def _claim_value(claim: Any, name: str, default: str = "") -> str:
|
| 80 |
+
if isinstance(claim, dict):
|
| 81 |
+
value = claim.get(name, default)
|
| 82 |
+
else:
|
| 83 |
+
value = getattr(claim, name, default)
|
| 84 |
+
return str(value or "")
|
| 85 |
+
|
| 86 |
+
|
| 87 |
+
def _find_source_key(path: str, source_map: Dict[str, str]) -> Optional[str]:
|
| 88 |
+
path = _normalise_rel(path)
|
| 89 |
+
if path in source_map:
|
| 90 |
+
return path
|
| 91 |
+
base = Path(path).name
|
| 92 |
+
for key in source_map:
|
| 93 |
+
if Path(key).name == base:
|
| 94 |
+
return key
|
| 95 |
+
return None
|
| 96 |
+
|
| 97 |
+
|
| 98 |
+
def _node_text(source: str, node: ast.AST) -> str:
|
| 99 |
+
if not hasattr(node, "lineno"):
|
| 100 |
+
return ""
|
| 101 |
+
lines = source.splitlines()
|
| 102 |
+
start = max(int(node.lineno) - 1, 0)
|
| 103 |
+
end = int(getattr(node, "end_lineno", node.lineno))
|
| 104 |
+
return "\n".join(lines[start:end])
|
| 105 |
+
|
| 106 |
+
|
| 107 |
+
def _find_entity_node(tree: ast.AST, entity: str, class_name: str = "") -> Optional[ast.AST]:
|
| 108 |
+
if not entity:
|
| 109 |
+
return None
|
| 110 |
+
for node in ast.walk(tree):
|
| 111 |
+
if isinstance(node, ast.ClassDef) and class_name and node.name == class_name:
|
| 112 |
+
for child in node.body:
|
| 113 |
+
if isinstance(child, (ast.FunctionDef, ast.AsyncFunctionDef)) and child.name == entity:
|
| 114 |
+
return child
|
| 115 |
+
if not class_name and isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)):
|
| 116 |
+
if node.name == entity:
|
| 117 |
+
return node
|
| 118 |
+
return None
|
| 119 |
+
|
| 120 |
+
|
| 121 |
+
def _decorator_contains(node: ast.AST, token: str) -> bool:
|
| 122 |
+
if not isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)):
|
| 123 |
+
return False
|
| 124 |
+
for dec in getattr(node, "decorator_list", []):
|
| 125 |
+
if token in ast.unparse(dec):
|
| 126 |
+
return True
|
| 127 |
+
return False
|
| 128 |
+
|
| 129 |
+
|
| 130 |
+
def _added_lines_for_hunks(hunks: Sequence[Any]) -> List[str]:
|
| 131 |
+
"""Return approximate added lines, enough for anti-hack/flakiness checks."""
|
| 132 |
+
added: List[str] = []
|
| 133 |
+
for hunk in hunks:
|
| 134 |
+
search_counts: Dict[str, int] = {}
|
| 135 |
+
for line in hunk.search_text.splitlines():
|
| 136 |
+
search_counts[line] = search_counts.get(line, 0) + 1
|
| 137 |
+
for line in hunk.replace_text.splitlines():
|
| 138 |
+
count = search_counts.get(line, 0)
|
| 139 |
+
if count:
|
| 140 |
+
search_counts[line] = count - 1
|
| 141 |
+
else:
|
| 142 |
+
added.append(line)
|
| 143 |
+
return added
|
| 144 |
+
|
| 145 |
+
|
| 146 |
+
def _added_lines_from_diff(diff_text: str) -> List[str]:
|
| 147 |
+
"""Return real added lines from a unified diff, ignoring file/hunk headers."""
|
| 148 |
+
added: List[str] = []
|
| 149 |
+
for line in (diff_text or "").splitlines():
|
| 150 |
+
if not line.startswith("+"):
|
| 151 |
+
continue
|
| 152 |
+
if line.startswith("+++") or line.startswith("+@@"):
|
| 153 |
+
continue
|
| 154 |
+
added.append(line[1:])
|
| 155 |
+
return added
|
| 156 |
+
|
| 157 |
+
|
| 158 |
+
def _hard_anti_hack_errors(hunks: Sequence[Any]) -> List[str]:
|
| 159 |
+
errors: List[str] = []
|
| 160 |
+
for hunk in hunks:
|
| 161 |
+
search_lower = hunk.search_text.lower()
|
| 162 |
+
replace_lower = hunk.replace_text.lower()
|
| 163 |
+
|
| 164 |
+
if len(re.findall(r"\bassert\b", replace_lower)) < len(re.findall(r"\bassert\b", search_lower)):
|
| 165 |
+
errors.append("anti_hack_assertion_deletion")
|
| 166 |
+
|
| 167 |
+
for pattern in _SLEEP_PATTERNS:
|
| 168 |
+
if replace_lower.count(pattern) > search_lower.count(pattern):
|
| 169 |
+
errors.append("anti_hack_sleep_injection")
|
| 170 |
+
break
|
| 171 |
+
|
| 172 |
+
for pattern in _SKIP_PATTERNS:
|
| 173 |
+
if replace_lower.count(pattern) > search_lower.count(pattern):
|
| 174 |
+
errors.append("anti_hack_test_skip")
|
| 175 |
+
break
|
| 176 |
+
|
| 177 |
+
introduced = "\n".join(_added_lines_for_hunks([hunk]))
|
| 178 |
+
if re.search(r"except\s*(?:Exception)?\s*:\s*(?:\n\s*)?pass\b", introduced):
|
| 179 |
+
errors.append("anti_hack_exception_swallowing")
|
| 180 |
+
|
| 181 |
+
return sorted(set(errors))
|
| 182 |
+
|
| 183 |
+
|
| 184 |
+
def _structural_issues(tree: ast.AST, rel_path: str) -> List[str]:
|
| 185 |
+
"""Detect obviously broken structure (empty bodies, etc.)."""
|
| 186 |
+
issues: List[str] = []
|
| 187 |
+
for node in ast.walk(tree):
|
| 188 |
+
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
| 189 |
+
if len(node.body) == 0:
|
| 190 |
+
issues.append(f"{rel_path}: empty body for function {node.name!r}")
|
| 191 |
+
if isinstance(node, ast.ClassDef):
|
| 192 |
+
if len(node.body) == 0:
|
| 193 |
+
issues.append(f"{rel_path}: empty class body for {node.name!r}")
|
| 194 |
+
return issues
|
| 195 |
+
|
| 196 |
+
|
| 197 |
+
def _causal_proximity_warnings(
|
| 198 |
+
modified_rel_paths: List[str],
|
| 199 |
+
failure_frontier: str,
|
| 200 |
+
call_chain: Optional[List[str]],
|
| 201 |
+
) -> List[str]:
|
| 202 |
+
"""Warn when patched files are far from the failure frontier (mirrors reward signal)."""
|
| 203 |
+
if not modified_rel_paths or not failure_frontier:
|
| 204 |
+
return []
|
| 205 |
+
|
| 206 |
+
frontier_file = failure_frontier.split(":")[0] if ":" in failure_frontier else failure_frontier
|
| 207 |
+
frontier_name = Path(frontier_file.replace("\\", "/")).name
|
| 208 |
+
|
| 209 |
+
hit = False
|
| 210 |
+
for rel in modified_rel_paths:
|
| 211 |
+
if Path(rel.replace("\\", "/")).name == frontier_name:
|
| 212 |
+
hit = True
|
| 213 |
+
break
|
| 214 |
+
pf_name = Path(rel.replace("\\", "/")).name.replace(".py", "")
|
| 215 |
+
if call_chain:
|
| 216 |
+
for frame in call_chain:
|
| 217 |
+
if pf_name in frame:
|
| 218 |
+
hit = True
|
| 219 |
+
break
|
| 220 |
+
if hit:
|
| 221 |
+
break
|
| 222 |
+
|
| 223 |
+
if not hit:
|
| 224 |
+
return [
|
| 225 |
+
f"patch targets {modified_rel_paths} but failure frontier is {failure_frontier!r} "
|
| 226 |
+
"(may be a workaround, not a localised fix)",
|
| 227 |
+
]
|
| 228 |
+
return []
|
| 229 |
+
|
| 230 |
+
|
| 231 |
+
def _defined_names(tree: ast.AST) -> Set[str]:
|
| 232 |
+
names: Set[str] = set(dir(builtins))
|
| 233 |
+
for node in ast.walk(tree):
|
| 234 |
+
if isinstance(node, ast.Import):
|
| 235 |
+
for alias in node.names:
|
| 236 |
+
names.add((alias.asname or alias.name.split(".")[0]))
|
| 237 |
+
elif isinstance(node, ast.ImportFrom):
|
| 238 |
+
for alias in node.names:
|
| 239 |
+
if alias.name != "*":
|
| 240 |
+
names.add(alias.asname or alias.name)
|
| 241 |
+
elif isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)):
|
| 242 |
+
names.add(node.name)
|
| 243 |
+
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
| 244 |
+
for arg in node.args.args:
|
| 245 |
+
names.add(arg.arg)
|
| 246 |
+
elif isinstance(node, ast.Assign):
|
| 247 |
+
for target in node.targets:
|
| 248 |
+
names.update(_target_names(target))
|
| 249 |
+
elif isinstance(node, ast.AnnAssign):
|
| 250 |
+
names.update(_target_names(node.target))
|
| 251 |
+
elif isinstance(node, ast.For):
|
| 252 |
+
names.update(_target_names(node.target))
|
| 253 |
+
elif isinstance(node, (ast.With, ast.AsyncWith)):
|
| 254 |
+
for item in node.items:
|
| 255 |
+
if item.optional_vars is not None:
|
| 256 |
+
names.update(_target_names(item.optional_vars))
|
| 257 |
+
return names
|
| 258 |
+
|
| 259 |
+
|
| 260 |
+
def _target_names(target: ast.AST) -> Set[str]:
|
| 261 |
+
if isinstance(target, ast.Name):
|
| 262 |
+
return {target.id}
|
| 263 |
+
if isinstance(target, (ast.Tuple, ast.List)):
|
| 264 |
+
out: Set[str] = set()
|
| 265 |
+
for elt in target.elts:
|
| 266 |
+
out.update(_target_names(elt))
|
| 267 |
+
return out
|
| 268 |
+
return set()
|
| 269 |
+
|
| 270 |
+
|
| 271 |
+
def _introduced_name_errors(src: str, added_lines: Sequence[str], rel: str) -> List[str]:
|
| 272 |
+
"""Catch common undefined names introduced by flaky-fix patches."""
|
| 273 |
+
try:
|
| 274 |
+
tree = ast.parse(src)
|
| 275 |
+
except SyntaxError:
|
| 276 |
+
return []
|
| 277 |
+
available = _defined_names(tree)
|
| 278 |
+
text = "\n".join(added_lines)
|
| 279 |
+
|
| 280 |
+
candidates = {
|
| 281 |
+
"threading", "asyncio", "pytest", "random", "datetime", "time",
|
| 282 |
+
"Lock", "RLock", "Semaphore", "Event",
|
| 283 |
+
}
|
| 284 |
+
errors: List[str] = []
|
| 285 |
+
for name in sorted(candidates):
|
| 286 |
+
if not re.search(rf"\b{re.escape(name)}\b", text):
|
| 287 |
+
continue
|
| 288 |
+
if name in available:
|
| 289 |
+
continue
|
| 290 |
+
# Attribute access like threading.Lock requires the module name. Bare
|
| 291 |
+
# RLock requires a direct import or definition.
|
| 292 |
+
errors.append(f"undefined_name: {name} in {rel}")
|
| 293 |
+
return errors
|
| 294 |
+
|
| 295 |
+
|
| 296 |
+
def _libcst_errors(src: str, rel: str) -> List[str]:
|
| 297 |
+
if not _LIBCST_AVAILABLE or cst is None:
|
| 298 |
+
return []
|
| 299 |
+
try:
|
| 300 |
+
module = cst.parse_module(src)
|
| 301 |
+
except Exception as exc:
|
| 302 |
+
return [f"libcst_parse_error in {rel}: {exc}"]
|
| 303 |
+
try:
|
| 304 |
+
if module.code != src:
|
| 305 |
+
return [f"libcst_roundtrip_mismatch in {rel}"]
|
| 306 |
+
except Exception as exc:
|
| 307 |
+
return [f"libcst_roundtrip_error in {rel}: {exc}"]
|
| 308 |
+
return []
|
| 309 |
+
|
| 310 |
+
|
| 311 |
+
def _reasoning_alignment_errors(
|
| 312 |
+
claims: Optional[Sequence[Any]],
|
| 313 |
+
original_sources: Dict[str, str],
|
| 314 |
+
modified_sources: Dict[str, str],
|
| 315 |
+
) -> List[str]:
|
| 316 |
+
if not claims:
|
| 317 |
+
return []
|
| 318 |
+
|
| 319 |
+
errors: List[str] = []
|
| 320 |
+
for claim in claims:
|
| 321 |
+
location = _claim_value(claim, "location")
|
| 322 |
+
category = _claim_value(claim, "category")
|
| 323 |
+
entity = _claim_value(claim, "entity")
|
| 324 |
+
reason = _claim_value(claim, "reason").lower()
|
| 325 |
+
file_path, class_name, func_name = _resolve_claim_location(location)
|
| 326 |
+
target_entity = func_name or entity
|
| 327 |
+
|
| 328 |
+
key = _find_source_key(file_path, modified_sources)
|
| 329 |
+
if key is None:
|
| 330 |
+
errors.append(
|
| 331 |
+
f"reasoning_action_misalignment: claim targets {file_path or '<unknown>'} "
|
| 332 |
+
"but patch modifies different files"
|
| 333 |
+
)
|
| 334 |
+
continue
|
| 335 |
+
|
| 336 |
+
pre = original_sources.get(key, "")
|
| 337 |
+
post = modified_sources.get(key, "")
|
| 338 |
+
try:
|
| 339 |
+
pre_tree = ast.parse(pre) if pre else None
|
| 340 |
+
post_tree = ast.parse(post) if post else None
|
| 341 |
+
except SyntaxError:
|
| 342 |
+
continue
|
| 343 |
+
|
| 344 |
+
if target_entity and pre_tree is not None and post_tree is not None:
|
| 345 |
+
pre_node = _find_entity_node(pre_tree, target_entity, class_name)
|
| 346 |
+
post_node = _find_entity_node(post_tree, target_entity, class_name)
|
| 347 |
+
if pre_node is not None and post_node is not None:
|
| 348 |
+
if _node_text(pre, pre_node) == _node_text(post, post_node):
|
| 349 |
+
errors.append(
|
| 350 |
+
f"reasoning_action_misalignment: claim targets {key}::{target_entity} "
|
| 351 |
+
"but that entity was not changed"
|
| 352 |
+
)
|
| 353 |
+
|
| 354 |
+
if category == "module_cache_pollution":
|
| 355 |
+
if _decorator_contains(post_node, "lru_cache") or _decorator_contains(post_node, "cache"):
|
| 356 |
+
errors.append(
|
| 357 |
+
f"reasoning_action_misalignment: cache decorator still present on {key}::{target_entity}"
|
| 358 |
+
)
|
| 359 |
+
|
| 360 |
+
post_node_text = _node_text(post, post_node)
|
| 361 |
+
uses_sync_primitive = bool(
|
| 362 |
+
re.search(r"\b(Lock|RLock|Semaphore|Event)\b", post_node_text)
|
| 363 |
+
or re.search(r"\bwith\s+[\w.]*_(?:lock|rlock|semaphore|event)\s*:", post_node_text)
|
| 364 |
+
or re.search(r"\bwith\s+[\w.]*\.(?:lock|rlock|semaphore|event)\s*:", post_node_text)
|
| 365 |
+
or re.search(r"\b(?:acquire|release)\s*\(", post_node_text)
|
| 366 |
+
)
|
| 367 |
+
if ("lock" in reason or "semaphore" in reason) and not uses_sync_primitive:
|
| 368 |
+
errors.append(
|
| 369 |
+
f"reasoning_action_misalignment: claim mentions synchronization "
|
| 370 |
+
f"but {key}::{target_entity} does not use a sync primitive"
|
| 371 |
+
)
|
| 372 |
+
elif pre_node is not None and post_node is None:
|
| 373 |
+
errors.append(
|
| 374 |
+
f"reasoning_action_misalignment: claim target {key}::{target_entity} was removed"
|
| 375 |
+
)
|
| 376 |
+
|
| 377 |
+
return sorted(set(errors))
|
| 378 |
+
|
| 379 |
+
|
| 380 |
+
def _flakiness_smell_errors(added_lines: Sequence[str]) -> Tuple[List[str], List[str]]:
|
| 381 |
+
text = "\n".join(added_lines)
|
| 382 |
+
errors: List[str] = []
|
| 383 |
+
warnings: List[str] = []
|
| 384 |
+
|
| 385 |
+
for code, pattern in _FLAKINESS_PATTERNS:
|
| 386 |
+
if pattern in text:
|
| 387 |
+
errors.append(code)
|
| 388 |
+
|
| 389 |
+
# New module-level mutable/global-ish assignments are a flaky-test smell.
|
| 390 |
+
for line in added_lines:
|
| 391 |
+
stripped = line.strip()
|
| 392 |
+
if not stripped or line[:1].isspace():
|
| 393 |
+
continue
|
| 394 |
+
if re.match(r"^[A-Z_a-z]\w*\s*=\s*(\[\]|\{\}|set\(\)|dict\(\)|list\(\))", stripped):
|
| 395 |
+
errors.append("flaky_global_mutable_assignment")
|
| 396 |
+
|
| 397 |
+
if re.search(r"@\s*(?:functools\.)?lru_cache\b", text) or "lru_cache(" in text:
|
| 398 |
+
warnings.append("potential_new_cache_pollution")
|
| 399 |
+
|
| 400 |
+
return sorted(set(errors)), sorted(set(warnings))
|
| 401 |
+
|
| 402 |
+
|
| 403 |
+
def _idempotency_issues(
|
| 404 |
+
repo_path: Path,
|
| 405 |
+
patch_text: str,
|
| 406 |
+
default_target: Optional[str],
|
| 407 |
+
modified_sources: Dict[str, str],
|
| 408 |
+
) -> Tuple[List[str], List[str]]:
|
| 409 |
+
second = simulate_search_replace_patch(
|
| 410 |
+
repo_path,
|
| 411 |
+
patch_text,
|
| 412 |
+
default_target=default_target,
|
| 413 |
+
pre_sources=modified_sources,
|
| 414 |
+
)
|
| 415 |
+
if not second.get("success"):
|
| 416 |
+
return [], [f"non_idempotent_patch: {second.get('error') or 'second_apply_failed'}"]
|
| 417 |
+
|
| 418 |
+
second_modified = second.get("modified_sources") or {}
|
| 419 |
+
for rel, src in second_modified.items():
|
| 420 |
+
if modified_sources.get(rel) != src:
|
| 421 |
+
return ["non_idempotent_patch"], []
|
| 422 |
+
return [], []
|
| 423 |
+
|
| 424 |
+
|
| 425 |
+
class PatchValidator:
|
| 426 |
+
"""Validate model-produced patches before they touch the repo on disk."""
|
| 427 |
+
|
| 428 |
+
def validate(
|
| 429 |
+
self,
|
| 430 |
+
patch_text: str,
|
| 431 |
+
*,
|
| 432 |
+
repo_path: Path,
|
| 433 |
+
pre_sources: Optional[Dict[str, str]] = None,
|
| 434 |
+
claims: Optional[Sequence[Any]] = None,
|
| 435 |
+
default_target: Optional[str] = None,
|
| 436 |
+
failure_frontier: str = "",
|
| 437 |
+
call_chain: Optional[List[str]] = None,
|
| 438 |
+
) -> ValidationResult:
|
| 439 |
+
"""Run all validation stages. Does not write files.
|
| 440 |
+
|
| 441 |
+
Args:
|
| 442 |
+
patch_text: Raw model patch (SEARCH/REPLACE hunks).
|
| 443 |
+
pre_sources: Optional snapshot rel path -> text; overrides disk for simulation.
|
| 444 |
+
claims: Optional structured think claims; used for reasoning-action alignment.
|
| 445 |
+
repo_path: Repository root.
|
| 446 |
+
default_target: File path when hunks omit ``---`` header.
|
| 447 |
+
failure_frontier: From observation (for proximity warnings).
|
| 448 |
+
call_chain: Call chain strings (for proximity warnings).
|
| 449 |
+
"""
|
| 450 |
+
errors: List[str] = []
|
| 451 |
+
warnings: List[str] = []
|
| 452 |
+
|
| 453 |
+
# ── Stage 1: format ─────────────────────────────────────────────────
|
| 454 |
+
text = (patch_text or "").strip()
|
| 455 |
+
if not text:
|
| 456 |
+
return ValidationResult(
|
| 457 |
+
is_valid=False,
|
| 458 |
+
errors=["empty_patch"],
|
| 459 |
+
score=0.0,
|
| 460 |
+
)
|
| 461 |
+
|
| 462 |
+
if not (
|
| 463 |
+
("<<<<<<<" in patch_text or "SEARCH" in patch_text)
|
| 464 |
+
and "=======" in patch_text
|
| 465 |
+
and ">>>>>>>" in patch_text
|
| 466 |
+
):
|
| 467 |
+
errors.append("invalid_patch_format: missing SEARCH/=======/REPLACE markers")
|
| 468 |
+
|
| 469 |
+
hunks = parse_search_replace_hunks(patch_text)
|
| 470 |
+
if not hunks:
|
| 471 |
+
errors.append("no_valid_hunks_found")
|
| 472 |
+
|
| 473 |
+
if hunks:
|
| 474 |
+
errors.extend(_hard_anti_hack_errors(hunks))
|
| 475 |
+
|
| 476 |
+
if errors:
|
| 477 |
+
return ValidationResult(
|
| 478 |
+
is_valid=False,
|
| 479 |
+
errors=errors,
|
| 480 |
+
warnings=warnings,
|
| 481 |
+
score=0.0,
|
| 482 |
+
)
|
| 483 |
+
|
| 484 |
+
# ── Stage 2: apply simulation (SEARCH must exist in source) ─────────
|
| 485 |
+
sim = simulate_search_replace_patch(
|
| 486 |
+
repo_path,
|
| 487 |
+
patch_text,
|
| 488 |
+
default_target=default_target,
|
| 489 |
+
pre_sources=pre_sources,
|
| 490 |
+
)
|
| 491 |
+
|
| 492 |
+
if not sim.get("success"):
|
| 493 |
+
err = sim.get("error") or "simulate_failed"
|
| 494 |
+
errors.append(f"apply_simulation_failed: {err}")
|
| 495 |
+
return ValidationResult(
|
| 496 |
+
is_valid=False,
|
| 497 |
+
errors=errors,
|
| 498 |
+
warnings=warnings,
|
| 499 |
+
score=0.0,
|
| 500 |
+
simulate_result=sim,
|
| 501 |
+
)
|
| 502 |
+
|
| 503 |
+
if sim.get("fuzzy_applied"):
|
| 504 |
+
warnings.append(
|
| 505 |
+
"fuzzy_indent_match_used: SEARCH was not an exact substring; "
|
| 506 |
+
"indentation-normalised match was used",
|
| 507 |
+
)
|
| 508 |
+
|
| 509 |
+
modified_sources: Dict[str, str] = sim.get("modified_sources") or {}
|
| 510 |
+
original_sources: Dict[str, str] = sim.get("original_sources") or sim.get("rollback_snapshots") or {}
|
| 511 |
+
lines_changed = int(sim.get("lines_changed") or 0)
|
| 512 |
+
# Prefer the simulated diff for smell checks. Semantic/fuzzy fallbacks
|
| 513 |
+
# may clean up malformed model hunk text before producing final code.
|
| 514 |
+
added_lines = _added_lines_from_diff(sim.get("diff") or "")
|
| 515 |
+
if not added_lines:
|
| 516 |
+
added_lines = _added_lines_for_hunks(hunks)
|
| 517 |
+
|
| 518 |
+
# ── Stage 5 (partial): minimal destructiveness ─────────────────────
|
| 519 |
+
if lines_changed > 120:
|
| 520 |
+
errors.append(f"patch_too_large: {lines_changed} lines changed (max 120)")
|
| 521 |
+
elif lines_changed > 80:
|
| 522 |
+
warnings.append(f"large_patch: {lines_changed} lines changed")
|
| 523 |
+
|
| 524 |
+
if errors:
|
| 525 |
+
return ValidationResult(
|
| 526 |
+
is_valid=False,
|
| 527 |
+
errors=errors,
|
| 528 |
+
warnings=warnings,
|
| 529 |
+
score=0.0,
|
| 530 |
+
simulate_result=sim,
|
| 531 |
+
)
|
| 532 |
+
|
| 533 |
+
# ── Stage 2b: reasoning-to-action semantic bridge ──────────────────
|
| 534 |
+
errors.extend(
|
| 535 |
+
_reasoning_alignment_errors(
|
| 536 |
+
claims=claims,
|
| 537 |
+
original_sources=original_sources,
|
| 538 |
+
modified_sources=modified_sources,
|
| 539 |
+
)
|
| 540 |
+
)
|
| 541 |
+
|
| 542 |
+
smell_errors, smell_warnings = _flakiness_smell_errors(added_lines)
|
| 543 |
+
errors.extend(smell_errors)
|
| 544 |
+
warnings.extend(smell_warnings)
|
| 545 |
+
|
| 546 |
+
if errors:
|
| 547 |
+
return ValidationResult(
|
| 548 |
+
is_valid=False,
|
| 549 |
+
errors=sorted(set(errors)),
|
| 550 |
+
warnings=warnings,
|
| 551 |
+
score=0.0,
|
| 552 |
+
simulate_result=sim,
|
| 553 |
+
)
|
| 554 |
+
|
| 555 |
+
# ── Stages 3–4–5: syntax, compile, structure ───────────────────────
|
| 556 |
+
for rel, src in modified_sources.items():
|
| 557 |
+
if not rel.endswith(".py"):
|
| 558 |
+
continue
|
| 559 |
+
try:
|
| 560 |
+
tree = ast.parse(src)
|
| 561 |
+
except SyntaxError as exc:
|
| 562 |
+
errors.append(f"syntax_error in {rel}: {exc.msg} (line {exc.lineno})")
|
| 563 |
+
continue
|
| 564 |
+
|
| 565 |
+
try:
|
| 566 |
+
compile(src, rel, "exec")
|
| 567 |
+
except SyntaxError as exc:
|
| 568 |
+
errors.append(f"compile_error in {rel}: {exc.msg} (line {exc.lineno})")
|
| 569 |
+
|
| 570 |
+
issues = _structural_issues(tree, rel)
|
| 571 |
+
for msg in issues:
|
| 572 |
+
errors.append(f"structure: {msg}")
|
| 573 |
+
|
| 574 |
+
errors.extend(_libcst_errors(src, rel))
|
| 575 |
+
errors.extend(_introduced_name_errors(src, added_lines, rel))
|
| 576 |
+
|
| 577 |
+
if errors:
|
| 578 |
+
return ValidationResult(
|
| 579 |
+
is_valid=False,
|
| 580 |
+
errors=sorted(set(errors)),
|
| 581 |
+
warnings=warnings,
|
| 582 |
+
score=0.0,
|
| 583 |
+
simulate_result=sim,
|
| 584 |
+
)
|
| 585 |
+
|
| 586 |
+
idempotency_errors, idempotency_warnings = _idempotency_issues(
|
| 587 |
+
repo_path=repo_path,
|
| 588 |
+
patch_text=patch_text,
|
| 589 |
+
default_target=default_target,
|
| 590 |
+
modified_sources=modified_sources,
|
| 591 |
+
)
|
| 592 |
+
warnings.extend(idempotency_errors)
|
| 593 |
+
warnings.extend(idempotency_warnings)
|
| 594 |
+
|
| 595 |
+
if errors:
|
| 596 |
+
return ValidationResult(
|
| 597 |
+
is_valid=False,
|
| 598 |
+
errors=sorted(set(errors)),
|
| 599 |
+
warnings=warnings,
|
| 600 |
+
score=0.0,
|
| 601 |
+
simulate_result=sim,
|
| 602 |
+
)
|
| 603 |
+
|
| 604 |
+
# ── Stage 6: causal proximity (warnings only) ───────────────────────
|
| 605 |
+
modified_rels = list(modified_sources.keys())
|
| 606 |
+
warnings.extend(
|
| 607 |
+
_causal_proximity_warnings(modified_rels, failure_frontier, call_chain or [])
|
| 608 |
+
)
|
| 609 |
+
|
| 610 |
+
# ── Score 0..1 for reward shaping ─────────────────────────────────
|
| 611 |
+
score = 1.0
|
| 612 |
+
if sim.get("noop"):
|
| 613 |
+
score -= 0.35
|
| 614 |
+
warnings.append("noop_patch: no effective line change")
|
| 615 |
+
if sim.get("fuzzy_applied"):
|
| 616 |
+
score -= 0.1
|
| 617 |
+
if lines_changed > 40:
|
| 618 |
+
score -= 0.05
|
| 619 |
+
score = max(0.0, min(1.0, score))
|
| 620 |
+
|
| 621 |
+
return ValidationResult(
|
| 622 |
+
is_valid=True,
|
| 623 |
+
errors=[],
|
| 624 |
+
warnings=warnings,
|
| 625 |
+
score=round(score, 3),
|
| 626 |
+
simulate_result=sim,
|
| 627 |
+
)
|
server/perf_sentinel.py
ADDED
|
@@ -0,0 +1,284 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Copyright (c) Meta Platforms, Inc. and affiliates.
|
| 2 |
+
# All rights reserved.
|
| 3 |
+
#
|
| 4 |
+
# This source code is licensed under the BSD-style license found in the
|
| 5 |
+
# LICENSE file in the root directory of this source tree.
|
| 6 |
+
|
| 7 |
+
"""
|
| 8 |
+
Pillar 4 — Performance Regression Sentinel.
|
| 9 |
+
|
| 10 |
+
After the agent applies a deep architectural fix, this module checks whether
|
| 11 |
+
the fix introduced a meaningful performance slowdown by running a small,
|
| 12 |
+
deterministic benchmark and comparing its timing distribution against a
|
| 13 |
+
pre-fix baseline using the Mann-Whitney U statistical test.
|
| 14 |
+
|
| 15 |
+
Design rationale:
|
| 16 |
+
A global threading.Lock on the DB connection will stop all flakiness, but
|
| 17 |
+
serialise every write and make the app 10x slower. FlakeForge v1 cannot
|
| 18 |
+
detect this — it only cares about pass/fail. The Sentinel closes that gap.
|
| 19 |
+
|
| 20 |
+
Reference:
|
| 21 |
+
Mostafa et al., "Risk-Aware Batch Testing for Performance Regression
|
| 22 |
+
Detection", arXiv 2025 — statistical Change-Point Detection on timing
|
| 23 |
+
distributions for CI without full benchmark suites.
|
| 24 |
+
"""
|
| 25 |
+
|
| 26 |
+
from __future__ import annotations
|
| 27 |
+
|
| 28 |
+
import logging
|
| 29 |
+
import math
|
| 30 |
+
from dataclasses import dataclass
|
| 31 |
+
from typing import Any, Dict, List, Optional, Tuple
|
| 32 |
+
|
| 33 |
+
logger = logging.getLogger(__name__)
|
| 34 |
+
|
| 35 |
+
# Optional scipy import — degrade gracefully when not installed
|
| 36 |
+
try:
|
| 37 |
+
from scipy import stats as _scipy_stats
|
| 38 |
+
_SCIPY_AVAILABLE = True
|
| 39 |
+
except ImportError:
|
| 40 |
+
_scipy_stats = None # type: ignore
|
| 41 |
+
_SCIPY_AVAILABLE = False
|
| 42 |
+
logger.warning(
|
| 43 |
+
"perf_sentinel: scipy not available — "
|
| 44 |
+
"falling back to median-ratio-only regression check."
|
| 45 |
+
)
|
| 46 |
+
|
| 47 |
+
try:
|
| 48 |
+
import numpy as _np
|
| 49 |
+
_NUMPY_AVAILABLE = True
|
| 50 |
+
except ImportError:
|
| 51 |
+
_np = None # type: ignore
|
| 52 |
+
_NUMPY_AVAILABLE = False
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
# ── Data classes ──────────────────────────────────────────────────────────────
|
| 56 |
+
|
| 57 |
+
@dataclass
|
| 58 |
+
class PerformanceBaseline:
|
| 59 |
+
"""Timing distribution captured during reset() before any patches."""
|
| 60 |
+
benchmark_test_id: str
|
| 61 |
+
timing_distribution_ms: List[float]
|
| 62 |
+
p50_ms: float
|
| 63 |
+
p95_ms: float
|
| 64 |
+
sample_count: int
|
| 65 |
+
|
| 66 |
+
@classmethod
|
| 67 |
+
def from_timings(cls, test_id: str, timings: List[float]) -> "PerformanceBaseline":
|
| 68 |
+
if not timings:
|
| 69 |
+
return cls(test_id, [], 0.0, 0.0, 0)
|
| 70 |
+
sorted_t = sorted(timings)
|
| 71 |
+
n = len(sorted_t)
|
| 72 |
+
p50 = sorted_t[n // 2]
|
| 73 |
+
p95 = sorted_t[int(n * 0.95)] if n > 1 else sorted_t[-1]
|
| 74 |
+
return cls(
|
| 75 |
+
benchmark_test_id=test_id,
|
| 76 |
+
timing_distribution_ms=timings,
|
| 77 |
+
p50_ms=p50,
|
| 78 |
+
p95_ms=p95,
|
| 79 |
+
sample_count=n,
|
| 80 |
+
)
|
| 81 |
+
|
| 82 |
+
|
| 83 |
+
@dataclass
|
| 84 |
+
class SentinelResult:
|
| 85 |
+
"""Result of a single regression check."""
|
| 86 |
+
is_regression: bool
|
| 87 |
+
median_ratio: float # post_median / baseline_median (1.0 = no change)
|
| 88 |
+
p_value: Optional[float] # Mann-Whitney p-value (None if scipy unavailable)
|
| 89 |
+
post_p50_ms: float
|
| 90 |
+
baseline_p50_ms: float
|
| 91 |
+
message: str
|
| 92 |
+
|
| 93 |
+
def to_dict(self) -> Dict[str, Any]:
|
| 94 |
+
return {
|
| 95 |
+
"regression": self.is_regression,
|
| 96 |
+
"median_ratio": round(self.median_ratio, 3),
|
| 97 |
+
"p_value": round(self.p_value, 4) if self.p_value is not None else None,
|
| 98 |
+
"post_p50_ms": round(self.post_p50_ms, 1),
|
| 99 |
+
"baseline_p50_ms": round(self.baseline_p50_ms, 1),
|
| 100 |
+
"message": self.message,
|
| 101 |
+
}
|
| 102 |
+
|
| 103 |
+
def penalty(self) -> float:
|
| 104 |
+
"""
|
| 105 |
+
Reward penalty for a detected regression.
|
| 106 |
+
Formula: 10 × log(median_ratio), capped at 25.
|
| 107 |
+
A 2× slowdown → 6.93 penalty.
|
| 108 |
+
A 10× slowdown → 23.03 penalty (worse than the 15-pt regression penalty).
|
| 109 |
+
"""
|
| 110 |
+
if not self.is_regression:
|
| 111 |
+
return 0.0
|
| 112 |
+
return min(25.0, 10.0 * math.log(max(1.0, self.median_ratio)))
|
| 113 |
+
|
| 114 |
+
|
| 115 |
+
# ── Main sentinel class ────────────────────────────────────────────────────────
|
| 116 |
+
|
| 117 |
+
class PerformanceSentinel:
|
| 118 |
+
"""
|
| 119 |
+
Captures a timing baseline and checks for regressions after patches.
|
| 120 |
+
|
| 121 |
+
Parameters
|
| 122 |
+
----------
|
| 123 |
+
slowdown_threshold:
|
| 124 |
+
Minimum median_ratio to consider a regression (default 1.5 = 50% slower).
|
| 125 |
+
p_value_cutoff:
|
| 126 |
+
Statistical significance level for the Mann-Whitney U test (default 0.05).
|
| 127 |
+
n_benchmark_runs:
|
| 128 |
+
Number of benchmark runs for both baseline and post-fix checks.
|
| 129 |
+
"""
|
| 130 |
+
|
| 131 |
+
def __init__(
|
| 132 |
+
self,
|
| 133 |
+
slowdown_threshold: float = 1.5,
|
| 134 |
+
p_value_cutoff: float = 0.05,
|
| 135 |
+
n_benchmark_runs: int = 20, # raised from 10 — Mann-Whitney U needs ≥20 per group
|
| 136 |
+
max_adaptive_samples: int = 50, # hard cap when re-sampling in inconclusive p-value zone
|
| 137 |
+
) -> None:
|
| 138 |
+
self.slowdown_threshold = slowdown_threshold
|
| 139 |
+
self.p_value_cutoff = p_value_cutoff
|
| 140 |
+
self.n_benchmark_runs = n_benchmark_runs
|
| 141 |
+
self.max_adaptive_samples = max_adaptive_samples
|
| 142 |
+
self._baseline: Optional[PerformanceBaseline] = None
|
| 143 |
+
|
| 144 |
+
@property
|
| 145 |
+
def has_baseline(self) -> bool:
|
| 146 |
+
return self._baseline is not None
|
| 147 |
+
|
| 148 |
+
def capture_baseline(self, runner: Any, benchmark_test_id: str) -> PerformanceBaseline:
|
| 149 |
+
"""
|
| 150 |
+
Run the benchmark test n times via `runner` and store the timing baseline.
|
| 151 |
+
|
| 152 |
+
``runner`` must expose a ``run_test_n_times(test_id, n)`` method that
|
| 153 |
+
returns a list of RunRecord objects with a ``duration_ms`` field.
|
| 154 |
+
"""
|
| 155 |
+
records = runner.run_test_n_times(benchmark_test_id, n=self.n_benchmark_runs)
|
| 156 |
+
timings = [float(r.duration_ms) for r in records]
|
| 157 |
+
self._baseline = PerformanceBaseline.from_timings(benchmark_test_id, timings)
|
| 158 |
+
logger.debug(
|
| 159 |
+
"perf_sentinel: baseline captured | test=%s p50=%.1fms p95=%.1fms n=%d",
|
| 160 |
+
benchmark_test_id,
|
| 161 |
+
self._baseline.p50_ms,
|
| 162 |
+
self._baseline.p95_ms,
|
| 163 |
+
self._baseline.sample_count,
|
| 164 |
+
)
|
| 165 |
+
return self._baseline
|
| 166 |
+
|
| 167 |
+
def check_regression(
|
| 168 |
+
self, runner: Any, benchmark_test_id: Optional[str] = None
|
| 169 |
+
) -> SentinelResult:
|
| 170 |
+
"""
|
| 171 |
+
Run the benchmark again and compare against the stored baseline.
|
| 172 |
+
|
| 173 |
+
Returns a SentinelResult. If no baseline has been captured or the
|
| 174 |
+
benchmark test cannot be found, returns a safe no-regression result.
|
| 175 |
+
"""
|
| 176 |
+
if self._baseline is None:
|
| 177 |
+
return SentinelResult(
|
| 178 |
+
is_regression=False,
|
| 179 |
+
median_ratio=1.0,
|
| 180 |
+
p_value=None,
|
| 181 |
+
post_p50_ms=0.0,
|
| 182 |
+
baseline_p50_ms=0.0,
|
| 183 |
+
message="No baseline captured — skipping performance check.",
|
| 184 |
+
)
|
| 185 |
+
|
| 186 |
+
test_id = benchmark_test_id or self._baseline.benchmark_test_id
|
| 187 |
+
try:
|
| 188 |
+
post_records = runner.run_test_n_times(test_id, n=self.n_benchmark_runs)
|
| 189 |
+
except Exception as exc:
|
| 190 |
+
logger.warning("perf_sentinel: benchmark run failed: %s", exc)
|
| 191 |
+
return SentinelResult(
|
| 192 |
+
is_regression=False,
|
| 193 |
+
median_ratio=1.0,
|
| 194 |
+
p_value=None,
|
| 195 |
+
post_p50_ms=0.0,
|
| 196 |
+
baseline_p50_ms=self._baseline.p50_ms,
|
| 197 |
+
message=f"Benchmark run error — skipping: {exc}",
|
| 198 |
+
)
|
| 199 |
+
|
| 200 |
+
post_timings = [float(r.duration_ms) for r in post_records]
|
| 201 |
+
if not post_timings:
|
| 202 |
+
return SentinelResult(
|
| 203 |
+
is_regression=False,
|
| 204 |
+
median_ratio=1.0,
|
| 205 |
+
p_value=None,
|
| 206 |
+
post_p50_ms=0.0,
|
| 207 |
+
baseline_p50_ms=self._baseline.p50_ms,
|
| 208 |
+
message="No timings collected — skipping.",
|
| 209 |
+
)
|
| 210 |
+
|
| 211 |
+
post_p50 = sorted(post_timings)[len(post_timings) // 2]
|
| 212 |
+
baseline_p50 = self._baseline.p50_ms
|
| 213 |
+
|
| 214 |
+
median_ratio = (post_p50 / baseline_p50) if baseline_p50 > 0 else 1.0
|
| 215 |
+
|
| 216 |
+
# Statistical test (requires scipy)
|
| 217 |
+
p_value: Optional[float] = None
|
| 218 |
+
stat_significant = True # default: rely only on median ratio when scipy absent
|
| 219 |
+
if _SCIPY_AVAILABLE and len(self._baseline.timing_distribution_ms) >= 5:
|
| 220 |
+
try:
|
| 221 |
+
_, p_value = _scipy_stats.mannwhitneyu(
|
| 222 |
+
self._baseline.timing_distribution_ms,
|
| 223 |
+
post_timings,
|
| 224 |
+
alternative="less", # H1: baseline < post (i.e. post is slower)
|
| 225 |
+
)
|
| 226 |
+
stat_significant = p_value < self.p_value_cutoff
|
| 227 |
+
|
| 228 |
+
# ── Adaptive re-sampling ─────────────────────────────────────────
|
| 229 |
+
# If p-value is in the inconclusive zone (0.05 – 0.15), gather up
|
| 230 |
+
# to max_adaptive_samples total observations and re-test.
|
| 231 |
+
# This prevents false-negatives on subtle 1.5x regressions.
|
| 232 |
+
_INCONCLUSIVE_UPPER = 0.15
|
| 233 |
+
adaptive_timings = list(post_timings)
|
| 234 |
+
while (
|
| 235 |
+
self.p_value_cutoff <= p_value <= _INCONCLUSIVE_UPPER
|
| 236 |
+
and len(adaptive_timings) < self.max_adaptive_samples
|
| 237 |
+
):
|
| 238 |
+
extra_needed = min(
|
| 239 |
+
10,
|
| 240 |
+
self.max_adaptive_samples - len(adaptive_timings),
|
| 241 |
+
)
|
| 242 |
+
try:
|
| 243 |
+
extra_records = runner.run_test_n_times(test_id, n=extra_needed)
|
| 244 |
+
adaptive_timings.extend([float(r.duration_ms) for r in extra_records])
|
| 245 |
+
_, p_value = _scipy_stats.mannwhitneyu(
|
| 246 |
+
self._baseline.timing_distribution_ms,
|
| 247 |
+
adaptive_timings,
|
| 248 |
+
alternative="less",
|
| 249 |
+
)
|
| 250 |
+
stat_significant = p_value < self.p_value_cutoff
|
| 251 |
+
post_p50 = sorted(adaptive_timings)[len(adaptive_timings) // 2]
|
| 252 |
+
median_ratio = (post_p50 / baseline_p50) if baseline_p50 > 0 else 1.0
|
| 253 |
+
except Exception as _exc:
|
| 254 |
+
logger.debug("perf_sentinel: adaptive sampling error: %s", _exc)
|
| 255 |
+
break
|
| 256 |
+
# ────────────────────────────────────────────────────────────────
|
| 257 |
+
|
| 258 |
+
except Exception as exc:
|
| 259 |
+
logger.debug("perf_sentinel: scipy test error: %s", exc)
|
| 260 |
+
|
| 261 |
+
is_regression = stat_significant and (median_ratio > self.slowdown_threshold)
|
| 262 |
+
|
| 263 |
+
if is_regression:
|
| 264 |
+
pct = (median_ratio - 1.0) * 100
|
| 265 |
+
msg = (
|
| 266 |
+
f"PERFORMANCE REGRESSION: {pct:.1f}% slower "
|
| 267 |
+
f"(baseline p50={baseline_p50:.1f}ms → post p50={post_p50:.1f}ms, "
|
| 268 |
+
f"ratio={median_ratio:.2f}x, p={p_value:.4f if p_value is not None else 'N/A'})"
|
| 269 |
+
)
|
| 270 |
+
else:
|
| 271 |
+
msg = (
|
| 272 |
+
f"Performance OK: ratio={median_ratio:.2f}x "
|
| 273 |
+
f"(baseline={baseline_p50:.1f}ms → post={post_p50:.1f}ms)"
|
| 274 |
+
)
|
| 275 |
+
|
| 276 |
+
logger.debug("perf_sentinel: %s", msg)
|
| 277 |
+
return SentinelResult(
|
| 278 |
+
is_regression=is_regression,
|
| 279 |
+
median_ratio=median_ratio,
|
| 280 |
+
p_value=p_value,
|
| 281 |
+
post_p50_ms=post_p50,
|
| 282 |
+
baseline_p50_ms=baseline_p50,
|
| 283 |
+
message=msg,
|
| 284 |
+
)
|
server/requirements.docker-runner.txt
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Minimal deps for the *sandbox* image used by DockerTestRunner
|
| 2 |
+
# (LOCAL_IMAGE_NAME, default flakeforge-env:latest).
|
| 3 |
+
# Install only this in a slim Dockerfile; keep OpenEnv/FastAPI/ML off this image.
|
| 4 |
+
#
|
| 5 |
+
# The runner mounts your repo at /workspace and runs:
|
| 6 |
+
# pytest <test_id> --tb=short -q --no-header
|
| 7 |
+
# Match what sample repos use (e.g. test_repos/*/requirements.txt).
|
| 8 |
+
|
| 9 |
+
pytest>=8.0.0
|
| 10 |
+
pytest-asyncio>=0.23.0
|
server/requirements.txt
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
openenv-core[core]>=0.2.2
|
| 2 |
+
fastapi>=0.115.0
|
| 3 |
+
uvicorn>=0.24.0
|
| 4 |
+
pydantic>=2.8.0
|
| 5 |
+
pytest>=8.3.0
|
| 6 |
+
libcst>=1.4.0
|
| 7 |
+
tree-sitter>=0.22.0
|
| 8 |
+
tree-sitter-python>=0.21.0
|
| 9 |
+
tree-sitter-javascript>=0.21.0
|
| 10 |
+
chromadb>=0.5.0
|
| 11 |
+
sentence-transformers>=3.0.0
|
| 12 |
+
# V2 additions
|
| 13 |
+
scipy>=1.13.0
|
| 14 |
+
numpy>=1.26.0
|
| 15 |
+
pytest-asyncio>=0.23.0
|
server/reward.py
ADDED
|
@@ -0,0 +1,447 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Reward architecture — multi-signal verifiable reward, no LLM judge."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import math
|
| 6 |
+
import re
|
| 7 |
+
from collections import Counter
|
| 8 |
+
from typing import Any, Dict, List, Optional
|
| 9 |
+
|
| 10 |
+
try:
|
| 11 |
+
from models import (
|
| 12 |
+
FlakeForgeAction,
|
| 13 |
+
FlakeForgeObservation,
|
| 14 |
+
RewardBreakdown,
|
| 15 |
+
ROOT_CAUSE_TYPES,
|
| 16 |
+
RELATED_CATEGORIES,
|
| 17 |
+
ThinkClaim,
|
| 18 |
+
failure_mode_entropy,
|
| 19 |
+
)
|
| 20 |
+
except ImportError:
|
| 21 |
+
from ..models import (
|
| 22 |
+
FlakeForgeAction,
|
| 23 |
+
FlakeForgeObservation,
|
| 24 |
+
RewardBreakdown,
|
| 25 |
+
ROOT_CAUSE_TYPES,
|
| 26 |
+
RELATED_CATEGORIES,
|
| 27 |
+
ThinkClaim,
|
| 28 |
+
failure_mode_entropy,
|
| 29 |
+
)
|
| 30 |
+
|
| 31 |
+
try:
|
| 32 |
+
from agent.unified_agent import infer_category_from_patch
|
| 33 |
+
except ImportError:
|
| 34 |
+
try:
|
| 35 |
+
from ..agent.unified_agent import infer_category_from_patch
|
| 36 |
+
except ImportError:
|
| 37 |
+
def infer_category_from_patch(patch_text: str) -> str:
|
| 38 |
+
return "unknown"
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
def _claim_has_required_format(claim: Any) -> bool:
|
| 42 |
+
return (
|
| 43 |
+
getattr(claim, "category", "") in ROOT_CAUSE_TYPES
|
| 44 |
+
and bool(str(getattr(claim, "entity", "")).strip())
|
| 45 |
+
and bool(str(getattr(claim, "location", "")).strip())
|
| 46 |
+
and getattr(claim, "polarity", "") in ("present", "absent")
|
| 47 |
+
and bool(str(getattr(claim, "reason", "")).strip())
|
| 48 |
+
)
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
def _hunk_has_required_format(hunk: Any) -> bool:
|
| 52 |
+
return (
|
| 53 |
+
bool(str(getattr(hunk, "file", "")).strip())
|
| 54 |
+
and bool(str(getattr(hunk, "search", "")).strip())
|
| 55 |
+
and isinstance(getattr(hunk, "replace", None), str)
|
| 56 |
+
)
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
def compute_format_reward(action: FlakeForgeAction) -> float:
|
| 60 |
+
"""Score current JSON-first action format. Returns 0..1.
|
| 61 |
+
|
| 62 |
+
The model-facing schema now requires only:
|
| 63 |
+
- claim: category, entity, location, polarity, reason
|
| 64 |
+
- hunk: file, search, replace
|
| 65 |
+
Optional metadata such as claim_id, ast_node_type, hunk_id, rationale, and
|
| 66 |
+
addresses_claim should not affect format reward.
|
| 67 |
+
"""
|
| 68 |
+
score = 0.0
|
| 69 |
+
|
| 70 |
+
think_score = 0.0
|
| 71 |
+
st = action.structured_think
|
| 72 |
+
if st is not None:
|
| 73 |
+
valid_claims = [claim for claim in st.claims if _claim_has_required_format(claim)]
|
| 74 |
+
if valid_claims and st.format_penalty == 0.0:
|
| 75 |
+
think_score = 0.5
|
| 76 |
+
elif valid_claims and st.format_penalty > -0.5:
|
| 77 |
+
think_score = 0.4
|
| 78 |
+
elif st.claims:
|
| 79 |
+
think_score = 0.25
|
| 80 |
+
|
| 81 |
+
if think_score == 0.0 and action.think_text:
|
| 82 |
+
think = action.think_text.lower()
|
| 83 |
+
if ("root cause:" in think or '"category"' in think) and (
|
| 84 |
+
"confidence" in think or '"reason"' in think
|
| 85 |
+
):
|
| 86 |
+
think_score = 0.5
|
| 87 |
+
score += think_score
|
| 88 |
+
|
| 89 |
+
patch_score = 0.0
|
| 90 |
+
sp = action.structured_patch
|
| 91 |
+
if sp is not None:
|
| 92 |
+
valid_hunks = [hunk for hunk in sp.hunks if _hunk_has_required_format(hunk)]
|
| 93 |
+
if valid_hunks and sp.format_penalty == 0.0:
|
| 94 |
+
patch_score = 0.5
|
| 95 |
+
elif valid_hunks and sp.format_penalty > -0.5:
|
| 96 |
+
patch_score = 0.4
|
| 97 |
+
elif sp.hunks:
|
| 98 |
+
patch_score = 0.25
|
| 99 |
+
|
| 100 |
+
if patch_score == 0.0 and action.patch_text:
|
| 101 |
+
has_search = "<<<<<<< SEARCH" in action.patch_text or "<<<<<<<" in action.patch_text
|
| 102 |
+
has_replace = ">>>>>>> REPLACE" in action.patch_text or ">>>>>>>" in action.patch_text
|
| 103 |
+
has_sep = "=======" in action.patch_text
|
| 104 |
+
if has_search and has_replace and has_sep:
|
| 105 |
+
patch_score = 0.5
|
| 106 |
+
elif has_search or has_replace:
|
| 107 |
+
patch_score = 0.25
|
| 108 |
+
score += patch_score
|
| 109 |
+
|
| 110 |
+
return round(min(score, 1.0), 2)
|
| 111 |
+
|
| 112 |
+
|
| 113 |
+
def compute_compile_reward(
|
| 114 |
+
patch_applied_successfully: bool,
|
| 115 |
+
syntax_error: Optional[str] = None,
|
| 116 |
+
*,
|
| 117 |
+
rejected_by_validator: bool = False,
|
| 118 |
+
rolled_back: bool = False,
|
| 119 |
+
) -> float:
|
| 120 |
+
"""Score patch applicability and syntax correctness. Returns -1.0..1.0."""
|
| 121 |
+
if rejected_by_validator:
|
| 122 |
+
return -0.5
|
| 123 |
+
if rolled_back:
|
| 124 |
+
return 0.0
|
| 125 |
+
if not patch_applied_successfully:
|
| 126 |
+
return -1.0
|
| 127 |
+
if syntax_error:
|
| 128 |
+
return -0.5
|
| 129 |
+
return 1.0
|
| 130 |
+
|
| 131 |
+
|
| 132 |
+
def _potential(pass_rate: float) -> float:
|
| 133 |
+
"""Potential function Φ(pass_rate) for potential-based reward shaping.
|
| 134 |
+
|
| 135 |
+
Uses Φ = pass_rate^2 so improvements near 1.0 are worth more than
|
| 136 |
+
improvements near 0.0. Theory (Ng, Harada, Russell 1999) guarantees
|
| 137 |
+
that Φ(s') - Φ(s) preserves the optimal policy.
|
| 138 |
+
"""
|
| 139 |
+
return pass_rate ** 2
|
| 140 |
+
|
| 141 |
+
|
| 142 |
+
def compute_stability_reward(
|
| 143 |
+
baseline_pass_rate: float,
|
| 144 |
+
current_pass_rate: float,
|
| 145 |
+
) -> float:
|
| 146 |
+
"""Potential-based stability reward: Φ(current) - Φ(baseline). Returns ~-1.0..3.0."""
|
| 147 |
+
shaped = _potential(current_pass_rate) - _potential(baseline_pass_rate)
|
| 148 |
+
|
| 149 |
+
if current_pass_rate >= 1.0:
|
| 150 |
+
return max(2.0, round(shaped * 3.0, 4))
|
| 151 |
+
elif shaped > 0:
|
| 152 |
+
return round(min(shaped * 3.0, 1.5), 4)
|
| 153 |
+
elif shaped < 0:
|
| 154 |
+
return round(max(shaped * 5.0, -1.0), 4)
|
| 155 |
+
else:
|
| 156 |
+
return -0.1
|
| 157 |
+
|
| 158 |
+
|
| 159 |
+
def compute_causal_proximity_reward(
|
| 160 |
+
patch_files: List[str],
|
| 161 |
+
failure_frontier: str,
|
| 162 |
+
call_chain: List[str],
|
| 163 |
+
boundary_crossings: List[str],
|
| 164 |
+
) -> float:
|
| 165 |
+
"""Reward patches that touch code near the failure site. Returns -0.3..1.0."""
|
| 166 |
+
if not patch_files or not failure_frontier:
|
| 167 |
+
return 0.0
|
| 168 |
+
|
| 169 |
+
score = 0.0
|
| 170 |
+
frontier_file = failure_frontier.split(":")[0] if ":" in failure_frontier else failure_frontier
|
| 171 |
+
|
| 172 |
+
for pf in patch_files:
|
| 173 |
+
pf_name = pf.split("/")[-1] if "/" in pf else pf
|
| 174 |
+
frontier_name = frontier_file.split("/")[-1] if "/" in frontier_file else frontier_file
|
| 175 |
+
if pf_name == frontier_name:
|
| 176 |
+
score = 1.0
|
| 177 |
+
break
|
| 178 |
+
|
| 179 |
+
if score == 0.0:
|
| 180 |
+
for pf in patch_files:
|
| 181 |
+
pf_name = pf.split("/")[-1].replace(".py", "")
|
| 182 |
+
for frame in call_chain:
|
| 183 |
+
if pf_name in frame:
|
| 184 |
+
score = max(score, 0.5)
|
| 185 |
+
break
|
| 186 |
+
|
| 187 |
+
if score == 0.0:
|
| 188 |
+
score = -0.3
|
| 189 |
+
|
| 190 |
+
if boundary_crossings and score > 0:
|
| 191 |
+
score = min(score + 0.2, 1.0)
|
| 192 |
+
|
| 193 |
+
return round(score, 2)
|
| 194 |
+
|
| 195 |
+
|
| 196 |
+
def compute_entropy_reward(
|
| 197 |
+
pre_entropy: float,
|
| 198 |
+
post_entropy: float,
|
| 199 |
+
) -> float:
|
| 200 |
+
"""Reward reduction in failure mode entropy. Returns -0.5..1.0."""
|
| 201 |
+
if pre_entropy <= 0 and post_entropy <= 0:
|
| 202 |
+
return 0.5
|
| 203 |
+
|
| 204 |
+
delta = pre_entropy - post_entropy
|
| 205 |
+
if delta > 0:
|
| 206 |
+
return round(min(delta * 2.0, 1.0), 4)
|
| 207 |
+
elif delta < 0:
|
| 208 |
+
return round(max(delta * 1.5, -0.5), 4)
|
| 209 |
+
return 0.0
|
| 210 |
+
|
| 211 |
+
|
| 212 |
+
def compute_anti_hack_penalty(
|
| 213 |
+
patch_text: str,
|
| 214 |
+
files_modified: List[str],
|
| 215 |
+
lines_changed: int,
|
| 216 |
+
) -> float:
|
| 217 |
+
"""Penalise common reward-hacking patterns. Returns -2.0..0.0."""
|
| 218 |
+
penalty = 0.0
|
| 219 |
+
patch_lower = patch_text.lower()
|
| 220 |
+
|
| 221 |
+
if re.search(r"\bassert\b", patch_lower):
|
| 222 |
+
search_blocks = re.findall(r"<<<<<<< SEARCH\n(.*?)=======", patch_text, re.DOTALL)
|
| 223 |
+
replace_blocks = re.findall(r"=======\n(.*?)>>>>>>> REPLACE", patch_text, re.DOTALL)
|
| 224 |
+
|
| 225 |
+
for search, replace in zip(search_blocks, replace_blocks):
|
| 226 |
+
search_asserts = len(re.findall(r"\bassert\b", search.lower()))
|
| 227 |
+
replace_asserts = len(re.findall(r"\bassert\b", replace.lower()))
|
| 228 |
+
if search_asserts > replace_asserts:
|
| 229 |
+
penalty -= 0.5 * (search_asserts - replace_asserts)
|
| 230 |
+
|
| 231 |
+
sleep_patterns = ["time.sleep(", "await asyncio.sleep(", "sleep("]
|
| 232 |
+
for pattern in sleep_patterns:
|
| 233 |
+
if pattern in patch_lower:
|
| 234 |
+
replace_count = sum(
|
| 235 |
+
1 for b in re.findall(r"=======\n(.*?)>>>>>>> REPLACE", patch_text, re.DOTALL)
|
| 236 |
+
if pattern in b.lower()
|
| 237 |
+
)
|
| 238 |
+
search_count = sum(
|
| 239 |
+
1 for b in re.findall(r"<<<<<<< SEARCH\n(.*?)=======", patch_text, re.DOTALL)
|
| 240 |
+
if pattern in b.lower()
|
| 241 |
+
)
|
| 242 |
+
if replace_count > search_count:
|
| 243 |
+
penalty -= 0.3 * (replace_count - search_count)
|
| 244 |
+
|
| 245 |
+
if "except:" in patch_lower or "except Exception:" in patch_lower:
|
| 246 |
+
replace_blocks = re.findall(r"=======\n(.*?)>>>>>>> REPLACE", patch_text, re.DOTALL)
|
| 247 |
+
for block in replace_blocks:
|
| 248 |
+
if "except:" in block.lower() or "except exception:" in block.lower():
|
| 249 |
+
penalty -= 0.5
|
| 250 |
+
|
| 251 |
+
skip_patterns = ["@pytest.mark.skip", "@unittest.skip", "pytest.skip("]
|
| 252 |
+
for pattern in skip_patterns:
|
| 253 |
+
if pattern in patch_lower:
|
| 254 |
+
penalty -= 1.0
|
| 255 |
+
|
| 256 |
+
if lines_changed > 50:
|
| 257 |
+
penalty -= min((lines_changed - 50) / 100.0, 0.5)
|
| 258 |
+
|
| 259 |
+
return round(max(penalty, -2.0), 2)
|
| 260 |
+
|
| 261 |
+
|
| 262 |
+
def compute_reasoning_consistency(
|
| 263 |
+
predicted_category: str,
|
| 264 |
+
inferred_category_from_patch: str,
|
| 265 |
+
think_text: str,
|
| 266 |
+
patch_text: str,
|
| 267 |
+
) -> float:
|
| 268 |
+
"""Category match between predicted and patch-inferred root cause. Returns -0.5..0.5."""
|
| 269 |
+
if not predicted_category or not inferred_category_from_patch:
|
| 270 |
+
return 0.0
|
| 271 |
+
if predicted_category == inferred_category_from_patch:
|
| 272 |
+
return 0.5
|
| 273 |
+
related = RELATED_CATEGORIES.get(predicted_category, set())
|
| 274 |
+
if inferred_category_from_patch in related:
|
| 275 |
+
return 0.25
|
| 276 |
+
return -0.5
|
| 277 |
+
|
| 278 |
+
|
| 279 |
+
def compute_think_history_penalty(
|
| 280 |
+
action: FlakeForgeAction,
|
| 281 |
+
think_history: Optional[List[Dict[str, Any]]],
|
| 282 |
+
) -> float:
|
| 283 |
+
"""Mildly penalize repeating the same diagnosis without new evidence."""
|
| 284 |
+
if not think_history:
|
| 285 |
+
return 0.0
|
| 286 |
+
|
| 287 |
+
categories: set[str] = set()
|
| 288 |
+
entities: set[str] = set()
|
| 289 |
+
reasons: set[str] = set()
|
| 290 |
+
if action.structured_think and action.structured_think.claims:
|
| 291 |
+
for claim in action.structured_think.claims:
|
| 292 |
+
if claim.category:
|
| 293 |
+
categories.add(claim.category)
|
| 294 |
+
if claim.entity:
|
| 295 |
+
entities.add(claim.entity)
|
| 296 |
+
if claim.reason:
|
| 297 |
+
reasons.add(claim.reason[:35].lower().strip())
|
| 298 |
+
elif action.predicted_category:
|
| 299 |
+
categories.add(action.predicted_category)
|
| 300 |
+
|
| 301 |
+
if not categories and not entities and not reasons:
|
| 302 |
+
return 0.0
|
| 303 |
+
|
| 304 |
+
previous = think_history[-1]
|
| 305 |
+
repeated_category = bool(categories & set(previous.get("categories", [])))
|
| 306 |
+
repeated_entity = bool(entities & set(previous.get("entities", [])))
|
| 307 |
+
repeated_reason = bool(reasons & set(previous.get("reason_signatures", [])))
|
| 308 |
+
|
| 309 |
+
if repeated_category and (repeated_entity or repeated_reason):
|
| 310 |
+
return -0.25
|
| 311 |
+
return 0.0
|
| 312 |
+
|
| 313 |
+
|
| 314 |
+
def compute_verifiable_reward(
|
| 315 |
+
action: FlakeForgeAction,
|
| 316 |
+
observation: FlakeForgeObservation,
|
| 317 |
+
patch_result: Dict[str, Any],
|
| 318 |
+
post_run_results: List[Dict[str, Any]],
|
| 319 |
+
baseline_pass_rate: float,
|
| 320 |
+
pre_entropy: float,
|
| 321 |
+
oracle_score: Optional[float] = None,
|
| 322 |
+
regression_detected: bool = False,
|
| 323 |
+
think_history: Optional[List[Dict[str, Any]]] = None,
|
| 324 |
+
) -> RewardBreakdown:
|
| 325 |
+
"""Compute the full multi-signal verifiable reward."""
|
| 326 |
+
patch_applied = patch_result.get("success", False)
|
| 327 |
+
rejected_by_validator = bool(patch_result.get("rejected_by_validator", False))
|
| 328 |
+
rolled_back = bool(patch_result.get("rolled_back", False))
|
| 329 |
+
syntax_error = patch_result.get("syntax_error") if patch_applied else None
|
| 330 |
+
files_modified = patch_result.get("files_modified", [])
|
| 331 |
+
lines_changed = patch_result.get("lines_changed", 0)
|
| 332 |
+
noop_patch = bool(patch_result.get("noop", False))
|
| 333 |
+
regression_detected = bool(regression_detected or patch_result.get("regression_detected", False))
|
| 334 |
+
|
| 335 |
+
post_pass_count = sum(1 for r in post_run_results if r.get("passed", False))
|
| 336 |
+
if post_run_results:
|
| 337 |
+
post_pass_rate = post_pass_count / len(post_run_results)
|
| 338 |
+
post_errors = [r.get("error_type", "") for r in post_run_results if not r.get("passed", True)]
|
| 339 |
+
post_counter = Counter(e for e in post_errors if e)
|
| 340 |
+
if post_counter:
|
| 341 |
+
total = sum(post_counter.values())
|
| 342 |
+
post_entropy = -sum((c / total) * math.log2(c / total) for c in post_counter.values())
|
| 343 |
+
max_e = math.log2(len(post_counter)) if len(post_counter) > 1 else 1.0
|
| 344 |
+
post_entropy = post_entropy / max_e if max_e > 0 else 0.0
|
| 345 |
+
else:
|
| 346 |
+
post_entropy = 0.0
|
| 347 |
+
else:
|
| 348 |
+
# If no tests were run, assume pass rate and entropy haven't changed
|
| 349 |
+
post_pass_rate = observation.current_pass_rate
|
| 350 |
+
post_entropy = pre_entropy
|
| 351 |
+
|
| 352 |
+
inferred_cat = infer_category_from_patch(action.patch_text)
|
| 353 |
+
|
| 354 |
+
breakdown = RewardBreakdown()
|
| 355 |
+
|
| 356 |
+
# ── Hard Gates Evaluation ─────────────────────────────────────────────────
|
| 357 |
+
breakdown.format_reward = compute_format_reward(action)
|
| 358 |
+
breakdown.compile_reward = compute_compile_reward(
|
| 359 |
+
patch_applied,
|
| 360 |
+
syntax_error,
|
| 361 |
+
rejected_by_validator=rejected_by_validator,
|
| 362 |
+
rolled_back=rolled_back,
|
| 363 |
+
)
|
| 364 |
+
breakdown.regression_penalty = -1.5 if regression_detected else 0.0
|
| 365 |
+
breakdown.anti_hack_penalty = compute_anti_hack_penalty(
|
| 366 |
+
action.patch_text, files_modified, lines_changed,
|
| 367 |
+
)
|
| 368 |
+
|
| 369 |
+
# Fast-fail only for truly catastrophic issues (anti-hack or broken format).
|
| 370 |
+
# Compile/validation failures still get partial credit so GRPO groups have
|
| 371 |
+
# reward variance and can produce non-zero advantages.
|
| 372 |
+
if breakdown.format_reward < 0.75 or breakdown.anti_hack_penalty < 0.0:
|
| 373 |
+
breakdown.total_reward = -2.0 if breakdown.anti_hack_penalty < 0.0 else -1.0
|
| 374 |
+
return breakdown
|
| 375 |
+
|
| 376 |
+
if breakdown.compile_reward < 1.0:
|
| 377 |
+
breakdown.total_reward = round(
|
| 378 |
+
breakdown.format_reward * 0.5
|
| 379 |
+
+ breakdown.compile_reward * 1.0,
|
| 380 |
+
4,
|
| 381 |
+
)
|
| 382 |
+
return breakdown
|
| 383 |
+
|
| 384 |
+
# PatchValidator positive shaping
|
| 385 |
+
if patch_applied and patch_result.get("validation_score") is not None:
|
| 386 |
+
try:
|
| 387 |
+
vs = float(patch_result["validation_score"])
|
| 388 |
+
breakdown.patch_validation_signal = round(0.2 * max(0.0, min(1.0, vs)), 4)
|
| 389 |
+
except (TypeError, ValueError):
|
| 390 |
+
breakdown.patch_validation_signal = 0.0
|
| 391 |
+
else:
|
| 392 |
+
breakdown.patch_validation_signal = 0.0
|
| 393 |
+
|
| 394 |
+
breakdown.stability_reward = compute_stability_reward(baseline_pass_rate, post_pass_rate)
|
| 395 |
+
breakdown.causal_proximity_reward = compute_causal_proximity_reward(
|
| 396 |
+
files_modified,
|
| 397 |
+
observation.failure_frontier,
|
| 398 |
+
observation.call_chain_to_frontier,
|
| 399 |
+
observation.boundary_crossings,
|
| 400 |
+
)
|
| 401 |
+
breakdown.failure_entropy_reward = compute_entropy_reward(pre_entropy, post_entropy)
|
| 402 |
+
breakdown.noop_patch_penalty = -0.5 if patch_applied and noop_patch else 0.0
|
| 403 |
+
breakdown.think_history_penalty = compute_think_history_penalty(action, think_history)
|
| 404 |
+
|
| 405 |
+
# ── Oracle (mild reasoning reward) ──────────────
|
| 406 |
+
if oracle_score is not None:
|
| 407 |
+
breakdown.oracle_reasoning_reward = round(float(oracle_score), 4)
|
| 408 |
+
breakdown.reasoning_consistency_reward = 0.0
|
| 409 |
+
else:
|
| 410 |
+
breakdown.reasoning_consistency_reward = compute_reasoning_consistency(
|
| 411 |
+
action.predicted_category, inferred_cat, action.think_text, action.patch_text,
|
| 412 |
+
)
|
| 413 |
+
breakdown.oracle_reasoning_reward = 0.0
|
| 414 |
+
|
| 415 |
+
# ── Terminal bonus (scaled down to 4.0) ───────────────────────────────────
|
| 416 |
+
if patch_applied and post_run_results:
|
| 417 |
+
if post_pass_rate >= 1.0:
|
| 418 |
+
breakdown.terminal_bonus = 4.0 # Solving it = massive positive signal
|
| 419 |
+
elif post_pass_rate > baseline_pass_rate + 0.5:
|
| 420 |
+
breakdown.terminal_bonus = 2.0 # Major improvement
|
| 421 |
+
elif post_pass_rate > baseline_pass_rate + 0.3:
|
| 422 |
+
breakdown.terminal_bonus = 1.0 # Meaningful progress
|
| 423 |
+
|
| 424 |
+
# ── Total reward ──────────────────────────────────────────────────────────
|
| 425 |
+
oracle_component = (
|
| 426 |
+
breakdown.oracle_reasoning_reward * 1.0 # reduced weight 1.0 (was 2.5)
|
| 427 |
+
if oracle_score is not None
|
| 428 |
+
else breakdown.reasoning_consistency_reward * 0.5
|
| 429 |
+
)
|
| 430 |
+
|
| 431 |
+
breakdown.total_reward = round(
|
| 432 |
+
breakdown.format_reward * 0.5
|
| 433 |
+
+ breakdown.compile_reward * 1.0
|
| 434 |
+
+ breakdown.stability_reward * 3.0 # increased weight 3.0 (was 2.0)
|
| 435 |
+
+ breakdown.causal_proximity_reward * 0.5
|
| 436 |
+
+ breakdown.failure_entropy_reward * 0.5
|
| 437 |
+
+ breakdown.anti_hack_penalty * 1.5
|
| 438 |
+
+ breakdown.regression_penalty * 1.0
|
| 439 |
+
+ oracle_component
|
| 440 |
+
+ breakdown.patch_validation_signal * 1.0
|
| 441 |
+
+ breakdown.noop_patch_penalty * 1.0
|
| 442 |
+
+ breakdown.think_history_penalty * 1.0
|
| 443 |
+
+ breakdown.terminal_bonus * 1.0,
|
| 444 |
+
4,
|
| 445 |
+
)
|
| 446 |
+
|
| 447 |
+
return breakdown
|
server/state.py
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""V3 Episode State — enhanced for unified agent architecture.
|
| 2 |
+
|
| 3 |
+
Changes from V2:
|
| 4 |
+
- Removed hypothesis_stack (no hypothesis gating)
|
| 5 |
+
- Removed judge/teacher fields
|
| 6 |
+
- Added deep flakiness signals cache
|
| 7 |
+
- Added patch history tracking
|
| 8 |
+
"""
|
| 9 |
+
|
| 10 |
+
from __future__ import annotations
|
| 11 |
+
|
| 12 |
+
from pathlib import Path
|
| 13 |
+
from typing import Any, Dict, List, Optional
|
| 14 |
+
|
| 15 |
+
from pydantic import BaseModel, Field
|
| 16 |
+
|
| 17 |
+
try:
|
| 18 |
+
from models import RunRecord, PatchRecord, RewardBreakdown
|
| 19 |
+
except ImportError:
|
| 20 |
+
from ..models import RunRecord, PatchRecord, RewardBreakdown
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
class EpisodeState(BaseModel):
|
| 24 |
+
"""V3 server-side episode state."""
|
| 25 |
+
|
| 26 |
+
# Identity
|
| 27 |
+
episode_id: str
|
| 28 |
+
test_identifier: str
|
| 29 |
+
repo_path: str
|
| 30 |
+
|
| 31 |
+
# Step tracking
|
| 32 |
+
step_count: int = 0
|
| 33 |
+
max_steps: int = 8
|
| 34 |
+
done: bool = False
|
| 35 |
+
|
| 36 |
+
# Code snapshots
|
| 37 |
+
original_test_source: str = ""
|
| 38 |
+
original_source_under_test: str = ""
|
| 39 |
+
current_test_source: str = ""
|
| 40 |
+
current_source_under_test: str = ""
|
| 41 |
+
|
| 42 |
+
# Run data
|
| 43 |
+
run_history: List[RunRecord] = Field(default_factory=list)
|
| 44 |
+
baseline_pass_rate: float = 0.0
|
| 45 |
+
current_pass_rate: float = 0.0
|
| 46 |
+
baseline_entropy: float = 0.0
|
| 47 |
+
env_type: str = "unknown"
|
| 48 |
+
should_train: bool = True
|
| 49 |
+
preflight_result: Dict[str, Any] = Field(default_factory=dict)
|
| 50 |
+
|
| 51 |
+
# Patch tracking
|
| 52 |
+
patches_applied: List[PatchRecord] = Field(default_factory=list)
|
| 53 |
+
total_diff_lines: int = 0
|
| 54 |
+
|
| 55 |
+
# Deep flakiness signals (cached from initial detection)
|
| 56 |
+
module_cache_violations: List[str] = Field(default_factory=list)
|
| 57 |
+
fixture_scope_risks: List[str] = Field(default_factory=list)
|
| 58 |
+
mock_residue_sites: List[str] = Field(default_factory=list)
|
| 59 |
+
import_side_effect_files: List[str] = Field(default_factory=list)
|
| 60 |
+
async_contamination_alive: bool = False
|
| 61 |
+
|
| 62 |
+
# Causal frontier
|
| 63 |
+
failure_frontier: str = ""
|
| 64 |
+
call_chain_to_frontier: List[str] = Field(default_factory=list)
|
| 65 |
+
boundary_crossings: List[str] = Field(default_factory=list)
|
| 66 |
+
|
| 67 |
+
# iDFlakies signals
|
| 68 |
+
order_dependency_detected: bool = False
|
| 69 |
+
infrastructure_sensitive: bool = False
|
| 70 |
+
|
| 71 |
+
# Stack trace
|
| 72 |
+
failing_stack_trace: str = ""
|
| 73 |
+
last_error_type: Optional[str] = None
|
| 74 |
+
|
| 75 |
+
# Causal graph
|
| 76 |
+
causal_graph: Optional[Dict[str, Any]] = None
|
| 77 |
+
causal_hints: List[str] = Field(default_factory=list)
|
| 78 |
+
|
| 79 |
+
# Last action tracking (for multi-step episodes)
|
| 80 |
+
last_think_text: str = ""
|
| 81 |
+
last_patch_text: str = ""
|
| 82 |
+
last_reward: float = 0.0
|
| 83 |
+
last_reward_breakdown: Dict[str, float] = Field(default_factory=dict)
|
| 84 |
+
last_patch_result: Dict[str, Any] = Field(default_factory=dict)
|
| 85 |
+
last_done_reason: str = ""
|
| 86 |
+
|
| 87 |
+
# Per-step think summaries — powers diversity penalty + hypothesis trail prompt.
|
| 88 |
+
# Each entry: {step, categories, entities, reason_signatures, oracle_score,
|
| 89 |
+
# pass_rate_after, reward}
|
| 90 |
+
step_think_history: List[Dict[str, Any]] = Field(default_factory=list)
|
| 91 |
+
|
| 92 |
+
# File tree
|
| 93 |
+
file_tree: List[str] = Field(default_factory=list)
|
| 94 |
+
|
| 95 |
+
# Regression tracking
|
| 96 |
+
regression_detected: bool = False
|
| 97 |
+
|
| 98 |
+
@property
|
| 99 |
+
def steps_remaining(self) -> int:
|
| 100 |
+
return max(0, self.max_steps - self.step_count)
|
| 101 |
+
|
| 102 |
+
@property
|
| 103 |
+
def is_terminal(self) -> bool:
|
| 104 |
+
return self.done or self.step_count >= self.max_steps
|
| 105 |
+
|
| 106 |
+
@property
|
| 107 |
+
def pass_rate_delta(self) -> float:
|
| 108 |
+
return round(self.current_pass_rate - self.baseline_pass_rate, 4)
|
server/tools.py
ADDED
|
@@ -0,0 +1,1214 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import ast
|
| 4 |
+
import difflib
|
| 5 |
+
import itertools
|
| 6 |
+
import json
|
| 7 |
+
import re
|
| 8 |
+
import statistics
|
| 9 |
+
from collections import Counter
|
| 10 |
+
from pathlib import Path
|
| 11 |
+
from typing import Any, Dict, List
|
| 12 |
+
|
| 13 |
+
import libcst as cst
|
| 14 |
+
|
| 15 |
+
try:
|
| 16 |
+
from ..models import ASTSummary, FailurePattern, RunRecord
|
| 17 |
+
except ImportError:
|
| 18 |
+
from models import ASTSummary, FailurePattern, RunRecord
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
def list_repo_structure(root_path: str) -> List[dict]:
|
| 22 |
+
root = Path(root_path)
|
| 23 |
+
ignored_dirs = {"__pycache__", ".git", "node_modules", "venv", ".venv"}
|
| 24 |
+
entries: List[dict] = []
|
| 25 |
+
|
| 26 |
+
for path in root.rglob("*.py"):
|
| 27 |
+
if any(part in ignored_dirs for part in path.parts):
|
| 28 |
+
continue
|
| 29 |
+
content = path.read_text(encoding="utf-8", errors="ignore")
|
| 30 |
+
name = path.name
|
| 31 |
+
entries.append(
|
| 32 |
+
{
|
| 33 |
+
"path": str(path.relative_to(root)).replace("\\", "/"),
|
| 34 |
+
"is_test": name.startswith("test_") or name.endswith("_test.py"),
|
| 35 |
+
"size_bytes": path.stat().st_size,
|
| 36 |
+
"has_async": ("async def" in content) or ("await " in content),
|
| 37 |
+
}
|
| 38 |
+
)
|
| 39 |
+
return entries
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
def read_file_excerpt(path: str, start_line: int, end_line: int) -> str:
|
| 43 |
+
if end_line - start_line > 100:
|
| 44 |
+
raise ValueError("Cannot read more than 100 lines per call")
|
| 45 |
+
p = Path(path)
|
| 46 |
+
with p.open("r", encoding="utf-8", errors="ignore") as f:
|
| 47 |
+
lines = itertools.islice(f, max(start_line - 1, 0), end_line)
|
| 48 |
+
return "".join(lines)
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
def parse_ast_summary(path: str) -> ASTSummary:
|
| 52 |
+
p = Path(path)
|
| 53 |
+
if p.suffix in {".js", ".ts"}:
|
| 54 |
+
return _parse_js_ts_summary(p)
|
| 55 |
+
|
| 56 |
+
source = p.read_text(encoding="utf-8", errors="ignore")
|
| 57 |
+
return _parse_python_ast_summary(source)
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
def resolve_target_from_evidence(path: str, evidence: List[str]) -> Dict[str, Any]:
|
| 61 |
+
summary = parse_ast_summary(path)
|
| 62 |
+
if not evidence:
|
| 63 |
+
if summary.functions:
|
| 64 |
+
return {
|
| 65 |
+
"type": "function",
|
| 66 |
+
"identifier": summary.functions[0]["name"],
|
| 67 |
+
"line": summary.functions[0]["line_range"][0],
|
| 68 |
+
}
|
| 69 |
+
return {"type": "line", "identifier": "def test_", "line": 1}
|
| 70 |
+
|
| 71 |
+
source = Path(path).read_text(encoding="utf-8", errors="ignore")
|
| 72 |
+
|
| 73 |
+
for clue in evidence:
|
| 74 |
+
clue = clue.strip()
|
| 75 |
+
if not clue:
|
| 76 |
+
continue
|
| 77 |
+
|
| 78 |
+
fn_match = re.search(r"([A-Za-z_][A-Za-z0-9_]*)", clue)
|
| 79 |
+
if fn_match:
|
| 80 |
+
fn_name = fn_match.group(1)
|
| 81 |
+
for fn in summary.functions:
|
| 82 |
+
if fn["name"] == fn_name:
|
| 83 |
+
return {
|
| 84 |
+
"type": "function",
|
| 85 |
+
"identifier": fn_name,
|
| 86 |
+
"line": fn["line_range"][0],
|
| 87 |
+
}
|
| 88 |
+
|
| 89 |
+
if clue in source:
|
| 90 |
+
return {"type": "line", "identifier": clue, "line": _line_of_text(source, clue)}
|
| 91 |
+
|
| 92 |
+
if summary.external_calls:
|
| 93 |
+
return {"type": "call", "identifier": summary.external_calls[0], "line": 1}
|
| 94 |
+
if summary.functions:
|
| 95 |
+
return {"type": "function", "identifier": summary.functions[0]["name"], "line": summary.functions[0]["line_range"][0]}
|
| 96 |
+
return {"type": "line", "identifier": "def test_", "line": 1}
|
| 97 |
+
|
| 98 |
+
|
| 99 |
+
class _LoggingInjector(cst.CSTTransformer):
|
| 100 |
+
def __init__(self, targets: List[Dict[str, str]]) -> None:
|
| 101 |
+
self.targets = targets
|
| 102 |
+
|
| 103 |
+
def leave_FunctionDef(self, original_node: cst.FunctionDef, updated_node: cst.FunctionDef) -> cst.FunctionDef:
|
| 104 |
+
target = next((t for t in self.targets if t.get("function_name") == original_node.name.value), None)
|
| 105 |
+
if not target:
|
| 106 |
+
return updated_node
|
| 107 |
+
|
| 108 |
+
position = target.get("position", "entry")
|
| 109 |
+
fn_name = original_node.name.value
|
| 110 |
+
log_stmt = cst.parse_statement(
|
| 111 |
+
"print(json.dumps({"
|
| 112 |
+
"'timestamp': time.time(), "
|
| 113 |
+
"'thread_id': threading.get_ident(), "
|
| 114 |
+
"'task_id': _task_name(), "
|
| 115 |
+
f"'function': '{fn_name}', "
|
| 116 |
+
f"'event': '{position}'"
|
| 117 |
+
"}), file=sys.stderr)\n"
|
| 118 |
+
)
|
| 119 |
+
|
| 120 |
+
body_stmts = list(updated_node.body.body)
|
| 121 |
+
already_present = any("'function': '%s'" % fn_name in getattr(stmt, "code", "") for stmt in body_stmts)
|
| 122 |
+
if already_present:
|
| 123 |
+
return updated_node
|
| 124 |
+
|
| 125 |
+
if position == "exit":
|
| 126 |
+
body_stmts.append(log_stmt)
|
| 127 |
+
else:
|
| 128 |
+
body_stmts.insert(0, log_stmt)
|
| 129 |
+
|
| 130 |
+
return updated_node.with_changes(body=updated_node.body.with_changes(body=body_stmts))
|
| 131 |
+
|
| 132 |
+
|
| 133 |
+
def inject_logging(path: str, injection_points: List[Dict[str, str]]) -> str:
|
| 134 |
+
source = Path(path).read_text(encoding="utf-8", errors="ignore")
|
| 135 |
+
module = cst.parse_module(source)
|
| 136 |
+
|
| 137 |
+
header = (
|
| 138 |
+
"import asyncio\n"
|
| 139 |
+
"import json\n"
|
| 140 |
+
"import sys\n"
|
| 141 |
+
"import threading\n"
|
| 142 |
+
"import time\n\n"
|
| 143 |
+
"def _task_name():\n"
|
| 144 |
+
" try:\n"
|
| 145 |
+
" task = asyncio.current_task()\n"
|
| 146 |
+
" return task.get_name() if task else None\n"
|
| 147 |
+
" except Exception:\n"
|
| 148 |
+
" return None\n\n"
|
| 149 |
+
)
|
| 150 |
+
transformed = module.visit(_LoggingInjector(injection_points)).code
|
| 151 |
+
if "def _task_name():" not in transformed:
|
| 152 |
+
transformed = header + transformed
|
| 153 |
+
return transformed
|
| 154 |
+
|
| 155 |
+
|
| 156 |
+
def apply_ast_patch(path: str, patch_spec: Dict[str, Any]) -> Dict[str, Any]:
|
| 157 |
+
p = Path(path)
|
| 158 |
+
original = p.read_text(encoding="utf-8", errors="ignore")
|
| 159 |
+
|
| 160 |
+
try:
|
| 161 |
+
operation = patch_spec.get("operation")
|
| 162 |
+
target = patch_spec.get("target", {})
|
| 163 |
+
identifier = str(target.get("identifier", ""))
|
| 164 |
+
template = str(patch_spec.get("code_template", ""))
|
| 165 |
+
params = patch_spec.get("parameters", {})
|
| 166 |
+
rendered = template.format(**params)
|
| 167 |
+
|
| 168 |
+
if rendered and rendered in original and operation in {"insert_before", "insert_after", "add_decorator"}:
|
| 169 |
+
diff_data = compute_diff_from_sources(str(p), original, original)
|
| 170 |
+
return {
|
| 171 |
+
"success": True,
|
| 172 |
+
"diff": diff_data["unified_diff"],
|
| 173 |
+
"lines_changed": 0,
|
| 174 |
+
"ast_diff": diff_data["ast_diff"],
|
| 175 |
+
}
|
| 176 |
+
|
| 177 |
+
updated = _apply_patch_operation(original, operation, target, identifier, rendered)
|
| 178 |
+
p.write_text(updated, encoding="utf-8")
|
| 179 |
+
|
| 180 |
+
diff_data = compute_diff_from_sources(str(p), original, updated)
|
| 181 |
+
return {
|
| 182 |
+
"success": True,
|
| 183 |
+
"diff": diff_data["unified_diff"],
|
| 184 |
+
"lines_changed": diff_data["lines_changed"],
|
| 185 |
+
"ast_diff": diff_data["ast_diff"],
|
| 186 |
+
}
|
| 187 |
+
except Exception as exc:
|
| 188 |
+
return {
|
| 189 |
+
"success": False,
|
| 190 |
+
"diff": "",
|
| 191 |
+
"lines_changed": 0,
|
| 192 |
+
"ast_diff": {},
|
| 193 |
+
"error": str(exc),
|
| 194 |
+
}
|
| 195 |
+
|
| 196 |
+
|
| 197 |
+
def compute_diff(original_path: str, patched_source: str) -> Dict[str, Any]:
|
| 198 |
+
original = Path(original_path).read_text(encoding="utf-8", errors="ignore")
|
| 199 |
+
return compute_diff_from_sources(original_path, original, patched_source)
|
| 200 |
+
|
| 201 |
+
|
| 202 |
+
def compute_diff_from_sources(path: str, before_source: str, after_source: str) -> Dict[str, Any]:
|
| 203 |
+
unified_diff = _make_unified_diff(before_source, after_source, path)
|
| 204 |
+
lines_changed = _count_diff_lines(unified_diff)
|
| 205 |
+
|
| 206 |
+
before = _safe_summary_from_source(before_source)
|
| 207 |
+
after = _safe_summary_from_source(after_source)
|
| 208 |
+
|
| 209 |
+
before_funcs = {f["name"] for f in before.functions}
|
| 210 |
+
after_funcs = {f["name"] for f in after.functions}
|
| 211 |
+
before_calls = set(before.external_calls)
|
| 212 |
+
after_calls = set(after.external_calls)
|
| 213 |
+
|
| 214 |
+
ast_diff = {
|
| 215 |
+
"functions_added": sorted(after_funcs - before_funcs),
|
| 216 |
+
"functions_removed": sorted(before_funcs - after_funcs),
|
| 217 |
+
"functions_modified": sorted(before_funcs & after_funcs),
|
| 218 |
+
"calls_added": sorted(after_calls - before_calls),
|
| 219 |
+
"calls_removed": sorted(before_calls - after_calls),
|
| 220 |
+
}
|
| 221 |
+
|
| 222 |
+
return {
|
| 223 |
+
"unified_diff": unified_diff,
|
| 224 |
+
"lines_changed": lines_changed,
|
| 225 |
+
"ast_diff": ast_diff,
|
| 226 |
+
}
|
| 227 |
+
|
| 228 |
+
|
| 229 |
+
def get_failure_pattern(run_records: List[RunRecord]) -> FailurePattern:
|
| 230 |
+
if not run_records:
|
| 231 |
+
return FailurePattern(
|
| 232 |
+
pass_rate=0.0,
|
| 233 |
+
most_common_error=None,
|
| 234 |
+
error_distribution={},
|
| 235 |
+
duration_mean=0.0,
|
| 236 |
+
duration_std=0.0,
|
| 237 |
+
flakiness_score=0.0,
|
| 238 |
+
)
|
| 239 |
+
|
| 240 |
+
pass_rate = sum(1 for r in run_records if r.passed) / len(run_records)
|
| 241 |
+
errors = [r.error_type for r in run_records if r.error_type]
|
| 242 |
+
error_distribution = dict(Counter(errors))
|
| 243 |
+
most_common_error = None
|
| 244 |
+
if error_distribution:
|
| 245 |
+
most_common_error = max(error_distribution, key=error_distribution.get)
|
| 246 |
+
|
| 247 |
+
durations = [r.duration_ms for r in run_records]
|
| 248 |
+
duration_mean = statistics.fmean(durations)
|
| 249 |
+
duration_std = statistics.pstdev(durations) if len(durations) > 1 else 0.0
|
| 250 |
+
flakiness_score = 4.0 * pass_rate * (1.0 - pass_rate)
|
| 251 |
+
|
| 252 |
+
return FailurePattern(
|
| 253 |
+
pass_rate=pass_rate,
|
| 254 |
+
most_common_error=most_common_error,
|
| 255 |
+
error_distribution=error_distribution,
|
| 256 |
+
duration_mean=duration_mean,
|
| 257 |
+
duration_std=duration_std,
|
| 258 |
+
flakiness_score=flakiness_score,
|
| 259 |
+
)
|
| 260 |
+
|
| 261 |
+
|
| 262 |
+
def get_similar_fixes(root_cause_category: str, test_source: str, embedding_model: Any = None) -> List[Dict[str, str]]:
|
| 263 |
+
try:
|
| 264 |
+
import chromadb
|
| 265 |
+
from sentence_transformers import SentenceTransformer
|
| 266 |
+
|
| 267 |
+
client = chromadb.PersistentClient(path=str(Path.cwd() / ".flakeforge_chroma"))
|
| 268 |
+
collection = client.get_or_create_collection("flake_fixes")
|
| 269 |
+
|
| 270 |
+
if collection.count() == 0:
|
| 271 |
+
_seed_collection_from_seed_repos(collection)
|
| 272 |
+
|
| 273 |
+
model = embedding_model or SentenceTransformer("all-MiniLM-L6-v2")
|
| 274 |
+
query_embedding = model.encode(test_source).tolist()
|
| 275 |
+
|
| 276 |
+
where = {"root_cause_category": root_cause_category}
|
| 277 |
+
results = collection.query(query_embeddings=[query_embedding], n_results=3, where=where)
|
| 278 |
+
|
| 279 |
+
docs = results.get("documents", [[]])[0]
|
| 280 |
+
metadatas = results.get("metadatas", [[]])[0]
|
| 281 |
+
out: List[Dict[str, str]] = []
|
| 282 |
+
for idx, doc in enumerate(docs):
|
| 283 |
+
md = metadatas[idx] if idx < len(metadatas) else {}
|
| 284 |
+
payload = json.loads(doc)
|
| 285 |
+
out.append(
|
| 286 |
+
{
|
| 287 |
+
"root_cause": md.get("root_cause_category", root_cause_category),
|
| 288 |
+
"original": payload.get("original", ""),
|
| 289 |
+
"fixed": payload.get("fixed", ""),
|
| 290 |
+
"diff": payload.get("diff", ""),
|
| 291 |
+
"action": md.get("action", ""),
|
| 292 |
+
"repo": md.get("repo", "unknown"),
|
| 293 |
+
}
|
| 294 |
+
)
|
| 295 |
+
return out[:3]
|
| 296 |
+
except Exception:
|
| 297 |
+
return []
|
| 298 |
+
|
| 299 |
+
|
| 300 |
+
def _stack_trace_file_candidates(stack_trace: str, repo_path: Path) -> List[str]:
|
| 301 |
+
"""Extract repo-relative python file paths from a stack trace."""
|
| 302 |
+
if not stack_trace.strip():
|
| 303 |
+
return []
|
| 304 |
+
|
| 305 |
+
candidates: List[str] = []
|
| 306 |
+
for line in stack_trace.splitlines():
|
| 307 |
+
m = re.search(r'File\s+"([^"]+\.py)"', line)
|
| 308 |
+
if not m:
|
| 309 |
+
continue
|
| 310 |
+
raw_path = m.group(1).replace("\\", "/")
|
| 311 |
+
|
| 312 |
+
# Accept both absolute and relative forms.
|
| 313 |
+
abs_repo = repo_path.resolve().as_posix().rstrip("/")
|
| 314 |
+
if raw_path.startswith(abs_repo):
|
| 315 |
+
rel = raw_path[len(abs_repo):].lstrip("/")
|
| 316 |
+
if rel:
|
| 317 |
+
candidates.append(rel)
|
| 318 |
+
continue
|
| 319 |
+
|
| 320 |
+
if raw_path.startswith("./"):
|
| 321 |
+
candidates.append(raw_path[2:])
|
| 322 |
+
else:
|
| 323 |
+
candidates.append(raw_path)
|
| 324 |
+
|
| 325 |
+
# Keep order, de-duplicate.
|
| 326 |
+
seen = set()
|
| 327 |
+
ordered: List[str] = []
|
| 328 |
+
for c in candidates:
|
| 329 |
+
key = c.strip().replace("\\", "/")
|
| 330 |
+
if not key or key in seen:
|
| 331 |
+
continue
|
| 332 |
+
seen.add(key)
|
| 333 |
+
ordered.append(key)
|
| 334 |
+
return ordered
|
| 335 |
+
|
| 336 |
+
|
| 337 |
+
def build_agent_targeting_hints(
|
| 338 |
+
repo_path: str,
|
| 339 |
+
test_identifier: str,
|
| 340 |
+
failing_stack_trace: str,
|
| 341 |
+
source_under_test: str,
|
| 342 |
+
causal_frontier: str,
|
| 343 |
+
deep_signals: Dict[str, Any],
|
| 344 |
+
max_hints: int = 8,
|
| 345 |
+
) -> List[str]:
|
| 346 |
+
"""Build file-targeting hints for the agent from multiple evidence channels.
|
| 347 |
+
|
| 348 |
+
This function intentionally combines more than causal graph data:
|
| 349 |
+
- stack trace file extraction
|
| 350 |
+
- test identifier path
|
| 351 |
+
- import-based source-under-test extraction
|
| 352 |
+
- causal frontier file
|
| 353 |
+
- deep flakiness signal file lists
|
| 354 |
+
"""
|
| 355 |
+
root = Path(repo_path)
|
| 356 |
+
hints: List[str] = []
|
| 357 |
+
|
| 358 |
+
def add(path: str, reason: str, score: float) -> None:
|
| 359 |
+
p = path.strip().replace("\\", "/")
|
| 360 |
+
if not p:
|
| 361 |
+
return
|
| 362 |
+
hints.append(f"{score:.2f} | {p} | {reason}")
|
| 363 |
+
|
| 364 |
+
# 1) Test file gets high priority because many flaky fixes live in tests.
|
| 365 |
+
test_file = test_identifier.split("::", 1)[0].strip()
|
| 366 |
+
if test_file:
|
| 367 |
+
add(test_file, "target test from test_identifier", 1.00)
|
| 368 |
+
|
| 369 |
+
# 2) Stack trace frames often provide the strongest location hints.
|
| 370 |
+
for idx, rel in enumerate(_stack_trace_file_candidates(failing_stack_trace, root)):
|
| 371 |
+
score = max(0.70 - idx * 0.08, 0.35)
|
| 372 |
+
add(rel, "stack trace frame", score)
|
| 373 |
+
|
| 374 |
+
# 3) Causal frontier can narrow to the deepest failure frame.
|
| 375 |
+
if causal_frontier:
|
| 376 |
+
frontier_file = causal_frontier.split(":", 1)[0].strip()
|
| 377 |
+
if frontier_file:
|
| 378 |
+
add(frontier_file, "causal frontier failure site", 0.85)
|
| 379 |
+
|
| 380 |
+
# 4) Imported modules from source_under_test snapshot.
|
| 381 |
+
if source_under_test:
|
| 382 |
+
try:
|
| 383 |
+
summary = _parse_python_ast_summary(source_under_test)
|
| 384 |
+
for imp in summary.imports[:8]:
|
| 385 |
+
module_path = imp.split(".")[0].strip()
|
| 386 |
+
if module_path:
|
| 387 |
+
add(f"{module_path}.py", "import in source_under_test", 0.45)
|
| 388 |
+
except Exception:
|
| 389 |
+
pass
|
| 390 |
+
|
| 391 |
+
# 5) Deep signal file lists.
|
| 392 |
+
signal_lists = [
|
| 393 |
+
("module_cache_violations", "module-cache risk"),
|
| 394 |
+
("fixture_scope_risks", "fixture scope risk"),
|
| 395 |
+
("mock_residue_sites", "mock residue site"),
|
| 396 |
+
("import_side_effect_files", "import side-effect file"),
|
| 397 |
+
]
|
| 398 |
+
for key, label in signal_lists:
|
| 399 |
+
values = deep_signals.get(key) or []
|
| 400 |
+
if not isinstance(values, list):
|
| 401 |
+
continue
|
| 402 |
+
for rel in values[:5]:
|
| 403 |
+
add(str(rel), label, 0.55)
|
| 404 |
+
|
| 405 |
+
# Aggregate by path, keep max score and merged reasons.
|
| 406 |
+
by_path: Dict[str, Dict[str, Any]] = {}
|
| 407 |
+
for row in hints:
|
| 408 |
+
try:
|
| 409 |
+
score_text, path_text, reason_text = [p.strip() for p in row.split("|", 2)]
|
| 410 |
+
score = float(score_text)
|
| 411 |
+
except Exception:
|
| 412 |
+
continue
|
| 413 |
+
entry = by_path.setdefault(path_text, {"score": score, "reasons": []})
|
| 414 |
+
entry["score"] = max(entry["score"], score)
|
| 415 |
+
if reason_text not in entry["reasons"]:
|
| 416 |
+
entry["reasons"].append(reason_text)
|
| 417 |
+
|
| 418 |
+
ranked = sorted(
|
| 419 |
+
by_path.items(),
|
| 420 |
+
key=lambda item: item[1]["score"],
|
| 421 |
+
reverse=True,
|
| 422 |
+
)
|
| 423 |
+
|
| 424 |
+
output: List[str] = []
|
| 425 |
+
for path_text, payload in ranked[:max_hints]:
|
| 426 |
+
reasons = ", ".join(payload["reasons"][:2])
|
| 427 |
+
output.append(f"{path_text} (score={payload['score']:.2f}; {reasons})")
|
| 428 |
+
return output
|
| 429 |
+
|
| 430 |
+
|
| 431 |
+
def _seed_collection_from_seed_repos(collection: Any) -> None:
|
| 432 |
+
root = Path.cwd() / "seed_repos"
|
| 433 |
+
if not root.exists():
|
| 434 |
+
return
|
| 435 |
+
|
| 436 |
+
model = None
|
| 437 |
+
try:
|
| 438 |
+
from sentence_transformers import SentenceTransformer
|
| 439 |
+
|
| 440 |
+
model = SentenceTransformer("all-MiniLM-L6-v2")
|
| 441 |
+
except Exception:
|
| 442 |
+
pass
|
| 443 |
+
|
| 444 |
+
mapping = {
|
| 445 |
+
"timing_race": "TIMING_RACE",
|
| 446 |
+
"shared_state": "SHARED_STATE",
|
| 447 |
+
"external_dependency": "EXTERNAL_DEPENDENCY",
|
| 448 |
+
"order_dependency": "ORDER_DEPENDENCY",
|
| 449 |
+
"resource_leak": "RESOURCE_LEAK",
|
| 450 |
+
"nondeterminism": "NONDETERMINISM",
|
| 451 |
+
"compound_timing_shared": "TIMING_RACE",
|
| 452 |
+
"compound_external_nondeterminism": "EXTERNAL_DEPENDENCY",
|
| 453 |
+
}
|
| 454 |
+
action_mapping = {
|
| 455 |
+
"timing_race": "ADD_TIMING_GUARD",
|
| 456 |
+
"shared_state": "RESET_STATE",
|
| 457 |
+
"external_dependency": "MOCK_DEPENDENCY",
|
| 458 |
+
"order_dependency": "ADD_SYNCHRONIZATION",
|
| 459 |
+
"resource_leak": "RESET_STATE",
|
| 460 |
+
"nondeterminism": "SEED_RANDOMNESS",
|
| 461 |
+
"compound_timing_shared": "ADD_TIMING_GUARD",
|
| 462 |
+
"compound_external_nondeterminism": "MOCK_DEPENDENCY",
|
| 463 |
+
}
|
| 464 |
+
|
| 465 |
+
docs = []
|
| 466 |
+
embeddings = []
|
| 467 |
+
ids = []
|
| 468 |
+
metadatas = []
|
| 469 |
+
|
| 470 |
+
for repo_dir in sorted([p for p in root.iterdir() if p.is_dir()]):
|
| 471 |
+
test_file = repo_dir / "tests" / "test_flaky.py"
|
| 472 |
+
fixed_test = repo_dir / "solution" / "tests" / "test_flaky.py"
|
| 473 |
+
if not test_file.exists() or not fixed_test.exists():
|
| 474 |
+
continue
|
| 475 |
+
|
| 476 |
+
original = test_file.read_text(encoding="utf-8", errors="ignore")
|
| 477 |
+
fixed = fixed_test.read_text(encoding="utf-8", errors="ignore")
|
| 478 |
+
diff = _make_unified_diff(original, fixed, str(test_file))
|
| 479 |
+
|
| 480 |
+
payload = json.dumps({"original": original, "fixed": fixed, "diff": diff})
|
| 481 |
+
docs.append(payload)
|
| 482 |
+
ids.append(repo_dir.name)
|
| 483 |
+
metadatas.append(
|
| 484 |
+
{
|
| 485 |
+
"repo": repo_dir.name,
|
| 486 |
+
"root_cause_category": mapping.get(repo_dir.name, "NONDETERMINISM"),
|
| 487 |
+
"action": action_mapping.get(repo_dir.name, "GATHER_EVIDENCE"),
|
| 488 |
+
}
|
| 489 |
+
)
|
| 490 |
+
|
| 491 |
+
if model is not None:
|
| 492 |
+
embeddings.append(model.encode(original).tolist())
|
| 493 |
+
|
| 494 |
+
if not docs:
|
| 495 |
+
return
|
| 496 |
+
|
| 497 |
+
if embeddings:
|
| 498 |
+
collection.add(ids=ids, documents=docs, metadatas=metadatas, embeddings=embeddings)
|
| 499 |
+
else:
|
| 500 |
+
collection.add(ids=ids, documents=docs, metadatas=metadatas)
|
| 501 |
+
|
| 502 |
+
|
| 503 |
+
def _safe_summary_from_source(source: str) -> ASTSummary:
|
| 504 |
+
try:
|
| 505 |
+
return _parse_python_ast_summary(source)
|
| 506 |
+
except Exception:
|
| 507 |
+
return ASTSummary([], [], [], [], [], [])
|
| 508 |
+
|
| 509 |
+
|
| 510 |
+
def _parse_python_ast_summary(source: str) -> ASTSummary:
|
| 511 |
+
tree = ast.parse(source)
|
| 512 |
+
|
| 513 |
+
functions: List[Dict[str, Any]] = []
|
| 514 |
+
classes: List[Dict[str, Any]] = []
|
| 515 |
+
imports: List[str] = []
|
| 516 |
+
global_vars: List[str] = []
|
| 517 |
+
threading_primitives: List[str] = []
|
| 518 |
+
external_calls: List[str] = []
|
| 519 |
+
|
| 520 |
+
external_modules = {
|
| 521 |
+
"requests", "boto3", "httpx", "redis", "psycopg2",
|
| 522 |
+
# V2 expansion: missing real-world dependencies
|
| 523 |
+
"aiohttp", "grpc", "sqlalchemy", "asyncpg", "celery", "pika",
|
| 524 |
+
"subprocess", "time",
|
| 525 |
+
}
|
| 526 |
+
primitive_markers = {
|
| 527 |
+
"threading.Lock",
|
| 528 |
+
"threading.Event",
|
| 529 |
+
"asyncio.Event",
|
| 530 |
+
"asyncio.Lock",
|
| 531 |
+
"asyncio.Barrier",
|
| 532 |
+
"asyncio.sleep",
|
| 533 |
+
}
|
| 534 |
+
|
| 535 |
+
for node in ast.walk(tree):
|
| 536 |
+
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
| 537 |
+
functions.append(
|
| 538 |
+
{
|
| 539 |
+
"name": node.name,
|
| 540 |
+
"is_async": isinstance(node, ast.AsyncFunctionDef),
|
| 541 |
+
"args": [arg.arg for arg in node.args.args],
|
| 542 |
+
"decorators": [ast.unparse(d) for d in node.decorator_list],
|
| 543 |
+
"line_range": [getattr(node, "lineno", 0), getattr(node, "end_lineno", 0)],
|
| 544 |
+
}
|
| 545 |
+
)
|
| 546 |
+
elif isinstance(node, ast.ClassDef):
|
| 547 |
+
classes.append(
|
| 548 |
+
{
|
| 549 |
+
"name": node.name,
|
| 550 |
+
"bases": [ast.unparse(b) for b in node.bases],
|
| 551 |
+
"decorators": [ast.unparse(d) for d in node.decorator_list],
|
| 552 |
+
"line_range": [getattr(node, "lineno", 0), getattr(node, "end_lineno", 0)],
|
| 553 |
+
}
|
| 554 |
+
)
|
| 555 |
+
elif isinstance(node, ast.Import):
|
| 556 |
+
for alias in node.names:
|
| 557 |
+
imports.append(alias.name)
|
| 558 |
+
elif isinstance(node, ast.ImportFrom):
|
| 559 |
+
module = node.module or ""
|
| 560 |
+
for alias in node.names:
|
| 561 |
+
imports.append(f"{module}.{alias.name}" if module else alias.name)
|
| 562 |
+
|
| 563 |
+
for node in tree.body:
|
| 564 |
+
if isinstance(node, ast.Assign):
|
| 565 |
+
for target in node.targets:
|
| 566 |
+
if isinstance(target, ast.Name):
|
| 567 |
+
global_vars.append(target.id)
|
| 568 |
+
|
| 569 |
+
for node in ast.walk(tree):
|
| 570 |
+
if isinstance(node, ast.Call):
|
| 571 |
+
call_name = _node_to_name(node.func)
|
| 572 |
+
if call_name in primitive_markers:
|
| 573 |
+
threading_primitives.append(call_name)
|
| 574 |
+
for module in external_modules:
|
| 575 |
+
if call_name.startswith(f"{module}."):
|
| 576 |
+
external_calls.append(call_name)
|
| 577 |
+
|
| 578 |
+
return ASTSummary(
|
| 579 |
+
functions=functions,
|
| 580 |
+
classes=classes,
|
| 581 |
+
imports=sorted(set(imports)),
|
| 582 |
+
global_vars=sorted(set(global_vars)),
|
| 583 |
+
threading_primitives=sorted(set(threading_primitives)),
|
| 584 |
+
external_calls=sorted(set(external_calls)),
|
| 585 |
+
)
|
| 586 |
+
|
| 587 |
+
|
| 588 |
+
def _make_unified_diff(before: str, after: str, path: str) -> str:
|
| 589 |
+
return "".join(
|
| 590 |
+
difflib.unified_diff(
|
| 591 |
+
before.splitlines(keepends=True),
|
| 592 |
+
after.splitlines(keepends=True),
|
| 593 |
+
fromfile=f"a/{path}",
|
| 594 |
+
tofile=f"b/{path}",
|
| 595 |
+
n=3,
|
| 596 |
+
)
|
| 597 |
+
)
|
| 598 |
+
|
| 599 |
+
|
| 600 |
+
def _count_diff_lines(diff: str) -> int:
|
| 601 |
+
count = 0
|
| 602 |
+
for line in diff.splitlines():
|
| 603 |
+
if line.startswith("+++") or line.startswith("---"):
|
| 604 |
+
continue
|
| 605 |
+
if line.startswith("+") or line.startswith("-"):
|
| 606 |
+
count += 1
|
| 607 |
+
return count
|
| 608 |
+
|
| 609 |
+
|
| 610 |
+
def _node_to_name(node: ast.AST) -> str:
|
| 611 |
+
if isinstance(node, ast.Name):
|
| 612 |
+
return node.id
|
| 613 |
+
if isinstance(node, ast.Attribute):
|
| 614 |
+
root = _node_to_name(node.value)
|
| 615 |
+
return f"{root}.{node.attr}" if root else node.attr
|
| 616 |
+
return ""
|
| 617 |
+
|
| 618 |
+
|
| 619 |
+
def _line_of_text(source: str, text: str) -> int:
|
| 620 |
+
idx = source.find(text)
|
| 621 |
+
if idx < 0:
|
| 622 |
+
return 1
|
| 623 |
+
return source[:idx].count("\n") + 1
|
| 624 |
+
|
| 625 |
+
|
| 626 |
+
def _apply_patch_operation(source: str, operation: str, target: Dict[str, Any], identifier: str, rendered: str) -> str:
|
| 627 |
+
if operation == "ensure_reset_fixture":
|
| 628 |
+
scope = str(target.get("scope", "function"))
|
| 629 |
+
return _apply_reset_fixture(source, scope)
|
| 630 |
+
if operation == "ensure_retry_wrapper":
|
| 631 |
+
max_attempts = int(target.get("max_attempts", 2))
|
| 632 |
+
backoff_ms = int(target.get("backoff_ms", 100))
|
| 633 |
+
fn_name = str(target.get("function_name", "test"))
|
| 634 |
+
return _apply_retry_wrapper(source, fn_name, max_attempts, backoff_ms)
|
| 635 |
+
if operation == "ensure_seed_call":
|
| 636 |
+
library = str(target.get("library", "random"))
|
| 637 |
+
fn_name = str(target.get("function_name", "test"))
|
| 638 |
+
return _apply_seed_call(source, fn_name, library)
|
| 639 |
+
if operation == "add_decorator":
|
| 640 |
+
return _apply_add_decorator(source, target, rendered)
|
| 641 |
+
if operation == "replace_call":
|
| 642 |
+
return _apply_replace_call(source, identifier, rendered)
|
| 643 |
+
if operation == "wrap_with":
|
| 644 |
+
return _apply_wrap_with(source, target, rendered)
|
| 645 |
+
if operation in {"insert_before", "insert_after"}:
|
| 646 |
+
if target.get("type") == "function":
|
| 647 |
+
return _apply_insert_in_function(source, target, rendered, operation)
|
| 648 |
+
return _apply_textual_operation_idempotent(source, operation, identifier, rendered)
|
| 649 |
+
# ── V2 Deep-Action Operations (Bug 2 fix) ──────────────────────────────────
|
| 650 |
+
if operation == "refactor_concurrency_primitive":
|
| 651 |
+
return _apply_refactor_concurrency(source, target)
|
| 652 |
+
if operation == "isolate_boundary_call":
|
| 653 |
+
return _apply_isolate_boundary(source, target)
|
| 654 |
+
if operation == "extract_async_scope":
|
| 655 |
+
return _apply_extract_async_scope(source, target)
|
| 656 |
+
if operation == "harden_idempotency":
|
| 657 |
+
return _apply_harden_idempotency(source, target)
|
| 658 |
+
raise ValueError(f"Unsupported operation: {operation}")
|
| 659 |
+
|
| 660 |
+
|
| 661 |
+
def _apply_add_decorator(source: str, target: Dict[str, Any], decorator_code: str) -> str:
|
| 662 |
+
fn_name = str(target.get("identifier", "")).replace("def ", "").strip()
|
| 663 |
+
if decorator_code.startswith("@"):
|
| 664 |
+
decorator_code = decorator_code[1:]
|
| 665 |
+
|
| 666 |
+
module = cst.parse_module(source)
|
| 667 |
+
decorator_expr = cst.parse_expression(decorator_code)
|
| 668 |
+
|
| 669 |
+
class DecoratorAdder(cst.CSTTransformer):
|
| 670 |
+
def leave_FunctionDef(self, original_node: cst.FunctionDef, updated_node: cst.FunctionDef) -> cst.FunctionDef:
|
| 671 |
+
if fn_name and fn_name not in original_node.name.value and fn_name != "test":
|
| 672 |
+
return updated_node
|
| 673 |
+
existing = [d.decorator.code for d in updated_node.decorators]
|
| 674 |
+
if decorator_expr.code in existing:
|
| 675 |
+
return updated_node
|
| 676 |
+
return updated_node.with_changes(decorators=[*updated_node.decorators, cst.Decorator(decorator_expr)])
|
| 677 |
+
|
| 678 |
+
return module.visit(DecoratorAdder()).code
|
| 679 |
+
|
| 680 |
+
|
| 681 |
+
def _apply_replace_call(source: str, call_identifier: str, rendered: str) -> str:
|
| 682 |
+
module = cst.parse_module(source)
|
| 683 |
+
rendered_expr = cst.parse_expression(rendered)
|
| 684 |
+
|
| 685 |
+
class CallReplacer(cst.CSTTransformer):
|
| 686 |
+
def leave_Call(self, original_node: cst.Call, updated_node: cst.Call) -> cst.BaseExpression:
|
| 687 |
+
name = _cst_name(updated_node.func)
|
| 688 |
+
if name == call_identifier:
|
| 689 |
+
return rendered_expr
|
| 690 |
+
return updated_node
|
| 691 |
+
|
| 692 |
+
return module.visit(CallReplacer()).code
|
| 693 |
+
|
| 694 |
+
|
| 695 |
+
def _apply_wrap_with(source: str, target: Dict[str, Any], rendered: str) -> str:
|
| 696 |
+
fn_name = str(target.get("identifier", "")).strip()
|
| 697 |
+
with_expr = rendered.strip().split(":", 1)[0].replace("with ", "").strip()
|
| 698 |
+
|
| 699 |
+
module = cst.parse_module(source)
|
| 700 |
+
|
| 701 |
+
class WrapWithTransformer(cst.CSTTransformer):
|
| 702 |
+
def leave_FunctionDef(self, original_node: cst.FunctionDef, updated_node: cst.FunctionDef) -> cst.FunctionDef:
|
| 703 |
+
if fn_name and fn_name not in original_node.name.value and fn_name != "test":
|
| 704 |
+
return updated_node
|
| 705 |
+
with_stmt = cst.With(
|
| 706 |
+
items=[cst.WithItem(item=cst.parse_expression(with_expr))],
|
| 707 |
+
body=cst.IndentedBlock(body=list(updated_node.body.body)),
|
| 708 |
+
)
|
| 709 |
+
wrapped = with_stmt
|
| 710 |
+
return updated_node.with_changes(body=updated_node.body.with_changes(body=[wrapped]))
|
| 711 |
+
|
| 712 |
+
return module.visit(WrapWithTransformer()).code
|
| 713 |
+
|
| 714 |
+
|
| 715 |
+
def _apply_insert_in_function(source: str, target: Dict[str, Any], rendered: str, operation: str) -> str:
|
| 716 |
+
fn_name = str(target.get("identifier", "")).strip()
|
| 717 |
+
statement = cst.parse_statement(rendered + "\n")
|
| 718 |
+
|
| 719 |
+
module = cst.parse_module(source)
|
| 720 |
+
|
| 721 |
+
class InsertTransformer(cst.CSTTransformer):
|
| 722 |
+
def leave_FunctionDef(self, original_node: cst.FunctionDef, updated_node: cst.FunctionDef) -> cst.FunctionDef:
|
| 723 |
+
if fn_name and fn_name not in original_node.name.value and fn_name != "test":
|
| 724 |
+
return updated_node
|
| 725 |
+
body = list(updated_node.body.body)
|
| 726 |
+
if any(getattr(stmt, "code", "") == statement.code for stmt in body):
|
| 727 |
+
return updated_node
|
| 728 |
+
if operation == "insert_before":
|
| 729 |
+
body = [statement, *body]
|
| 730 |
+
else:
|
| 731 |
+
body = [*body, statement]
|
| 732 |
+
return updated_node.with_changes(body=updated_node.body.with_changes(body=body))
|
| 733 |
+
|
| 734 |
+
return module.visit(InsertTransformer()).code
|
| 735 |
+
|
| 736 |
+
|
| 737 |
+
def _apply_textual_operation_idempotent(source: str, operation: str, identifier: str, rendered: str) -> str:
|
| 738 |
+
if rendered in source:
|
| 739 |
+
return source
|
| 740 |
+
|
| 741 |
+
if operation in {"insert_before", "insert_after"} and identifier:
|
| 742 |
+
match = re.search(re.escape(identifier), source)
|
| 743 |
+
if not match:
|
| 744 |
+
raise ValueError(f"Target identifier not found: {identifier}")
|
| 745 |
+
|
| 746 |
+
insert = rendered + "\n"
|
| 747 |
+
if operation == "insert_before":
|
| 748 |
+
return source[: match.start()] + insert + source[match.start() :]
|
| 749 |
+
return source[: match.end()] + "\n" + rendered + source[match.end() :]
|
| 750 |
+
|
| 751 |
+
raise ValueError(f"Unsupported textual operation: {operation}")
|
| 752 |
+
|
| 753 |
+
|
| 754 |
+
def _cst_name(node: cst.CSTNode) -> str:
|
| 755 |
+
if isinstance(node, cst.Name):
|
| 756 |
+
return node.value
|
| 757 |
+
if isinstance(node, cst.Attribute):
|
| 758 |
+
left = _cst_name(node.value)
|
| 759 |
+
return f"{left}.{node.attr.value}" if left else node.attr.value
|
| 760 |
+
return ""
|
| 761 |
+
|
| 762 |
+
|
| 763 |
+
def _apply_reset_fixture(source: str, scope: str) -> str:
|
| 764 |
+
fixture_name = "_flakeforge_reset_state"
|
| 765 |
+
if f"def {fixture_name}(" in source:
|
| 766 |
+
return source
|
| 767 |
+
|
| 768 |
+
module = cst.parse_module(source)
|
| 769 |
+
fixture_code = (
|
| 770 |
+
f"@pytest.fixture(autouse=True, scope='{scope}')\n"
|
| 771 |
+
f"def {fixture_name}():\n"
|
| 772 |
+
" for _key, _value in list(globals().items()):\n"
|
| 773 |
+
" if _key.startswith('_') or _key in {'pytest', 'copy', '_flakeforge_reset_state'}:\n"
|
| 774 |
+
" continue\n"
|
| 775 |
+
" if isinstance(_value, dict):\n"
|
| 776 |
+
" _value.clear()\n"
|
| 777 |
+
" elif isinstance(_value, list):\n"
|
| 778 |
+
" _value.clear()\n"
|
| 779 |
+
" elif isinstance(_value, set):\n"
|
| 780 |
+
" _value.clear()\n"
|
| 781 |
+
" yield\n"
|
| 782 |
+
)
|
| 783 |
+
|
| 784 |
+
try:
|
| 785 |
+
fixture_stmt = cst.parse_statement(fixture_code)
|
| 786 |
+
if not isinstance(fixture_stmt, cst.FunctionDef):
|
| 787 |
+
return source
|
| 788 |
+
except Exception:
|
| 789 |
+
return source
|
| 790 |
+
|
| 791 |
+
updated_source = source
|
| 792 |
+
if "import pytest" not in source:
|
| 793 |
+
updated_source = "import pytest\n" + updated_source
|
| 794 |
+
|
| 795 |
+
updated_module = cst.parse_module(updated_source)
|
| 796 |
+
body = list(updated_module.body)
|
| 797 |
+
body.insert(0, fixture_stmt)
|
| 798 |
+
return updated_module.with_changes(body=body).code
|
| 799 |
+
|
| 800 |
+
|
| 801 |
+
def _apply_retry_wrapper(source: str, fn_name: str, max_attempts: int, backoff_ms: int) -> str:
|
| 802 |
+
helper_name = "_flakeforge_retry"
|
| 803 |
+
module = cst.parse_module(source)
|
| 804 |
+
|
| 805 |
+
class RetryDecoratorAdder(cst.CSTTransformer):
|
| 806 |
+
def leave_FunctionDef(self, original_node: cst.FunctionDef, updated_node: cst.FunctionDef) -> cst.FunctionDef:
|
| 807 |
+
if fn_name and fn_name not in original_node.name.value and fn_name != "test":
|
| 808 |
+
return updated_node
|
| 809 |
+
decorator_expr = cst.parse_expression(
|
| 810 |
+
f"{helper_name}(max_attempts={max_attempts}, backoff_ms={backoff_ms})"
|
| 811 |
+
)
|
| 812 |
+
existing = [d.decorator.code for d in updated_node.decorators]
|
| 813 |
+
if any(helper_name in value for value in existing):
|
| 814 |
+
return updated_node
|
| 815 |
+
return updated_node.with_changes(decorators=[*updated_node.decorators, cst.Decorator(decorator_expr)])
|
| 816 |
+
|
| 817 |
+
transformed = module.visit(RetryDecoratorAdder()).code
|
| 818 |
+
if f"def {helper_name}(" in transformed:
|
| 819 |
+
return transformed
|
| 820 |
+
|
| 821 |
+
helper_code = (
|
| 822 |
+
"\n"
|
| 823 |
+
"def _flakeforge_retry(max_attempts: int = 2, backoff_ms: int = 100):\n"
|
| 824 |
+
" def _decorator(fn):\n"
|
| 825 |
+
" def _wrapped(*args, **kwargs):\n"
|
| 826 |
+
" _last_exc = None\n"
|
| 827 |
+
" for _attempt in range(max_attempts):\n"
|
| 828 |
+
" try:\n"
|
| 829 |
+
" return fn(*args, **kwargs)\n"
|
| 830 |
+
" except Exception as _exc:\n"
|
| 831 |
+
" _last_exc = _exc\n"
|
| 832 |
+
" time.sleep(backoff_ms / 1000.0)\n"
|
| 833 |
+
" raise _last_exc\n"
|
| 834 |
+
" return _wrapped\n"
|
| 835 |
+
" return _decorator\n"
|
| 836 |
+
)
|
| 837 |
+
if "import time" not in transformed:
|
| 838 |
+
transformed = "import time\n" + transformed
|
| 839 |
+
return transformed + helper_code
|
| 840 |
+
|
| 841 |
+
|
| 842 |
+
def _apply_seed_call(source: str, fn_name: str, library: str) -> str:
|
| 843 |
+
module = cst.parse_module(source)
|
| 844 |
+
seed_statements: List[cst.BaseStatement] = []
|
| 845 |
+
if library in {"random", "both"}:
|
| 846 |
+
seed_statements.append(cst.parse_statement("random.seed(42)\n"))
|
| 847 |
+
if library in {"numpy", "both"}:
|
| 848 |
+
seed_statements.append(cst.parse_statement("numpy.random.seed(42)\n"))
|
| 849 |
+
|
| 850 |
+
class SeedInserter(cst.CSTTransformer):
|
| 851 |
+
def leave_FunctionDef(self, original_node: cst.FunctionDef, updated_node: cst.FunctionDef) -> cst.FunctionDef:
|
| 852 |
+
if fn_name and fn_name not in original_node.name.value and fn_name != "test":
|
| 853 |
+
return updated_node
|
| 854 |
+
body = list(updated_node.body.body)
|
| 855 |
+
existing_codes = [stmt.code for stmt in body]
|
| 856 |
+
to_insert: List[cst.BaseStatement] = []
|
| 857 |
+
for stmt in seed_statements:
|
| 858 |
+
if stmt.code not in existing_codes:
|
| 859 |
+
to_insert.append(stmt)
|
| 860 |
+
if not to_insert:
|
| 861 |
+
return updated_node
|
| 862 |
+
return updated_node.with_changes(body=updated_node.body.with_changes(body=[*to_insert, *body]))
|
| 863 |
+
|
| 864 |
+
transformed = module.visit(SeedInserter()).code
|
| 865 |
+
if library in {"random", "both"} and "import random" not in transformed:
|
| 866 |
+
transformed = "import random\n" + transformed
|
| 867 |
+
if library in {"numpy", "both"} and "import numpy" not in transformed:
|
| 868 |
+
transformed = "import numpy\n" + transformed
|
| 869 |
+
return transformed
|
| 870 |
+
|
| 871 |
+
|
| 872 |
+
# ── V2 Deep-Action Implementations (Bug 2 fix) ────────────────────────────────────
|
| 873 |
+
|
| 874 |
+
def _apply_refactor_concurrency(source: str, target: Dict[str, Any]) -> str:
|
| 875 |
+
"""
|
| 876 |
+
Swap a threading/sync primitive for a safer alternative.
|
| 877 |
+
|
| 878 |
+
Production fixes:
|
| 879 |
+
1. Matches the FULL dotted name (e.g. "threading.Lock") — not a loose
|
| 880 |
+
substring — so "asyncio.Lock" and "FileLock" are never accidentally swapped.
|
| 881 |
+
2. Also rewrites the import statement to match the new location.
|
| 882 |
+
If `from threading import Lock` exists, changes it to the new module.
|
| 883 |
+
If `import threading` exists and the new primitive is in asyncio, adds
|
| 884 |
+
`import asyncio` if not already present.
|
| 885 |
+
"""
|
| 886 |
+
from_primitive: str = str(target.get("from_primitive", "threading.Lock"))
|
| 887 |
+
to_primitive: str = str(target.get("to_primitive", "threading.RLock"))
|
| 888 |
+
|
| 889 |
+
# Derive module + class name for import rewriting
|
| 890 |
+
from_module = from_primitive.rsplit(".", 1)[0] if "." in from_primitive else ""
|
| 891 |
+
to_module = to_primitive.rsplit(".", 1)[0] if "." in to_primitive else ""
|
| 892 |
+
to_class = to_primitive.rsplit(".", 1)[-1]
|
| 893 |
+
|
| 894 |
+
module = cst.parse_module(source)
|
| 895 |
+
|
| 896 |
+
class PrimitiveSwapper(cst.CSTTransformer):
|
| 897 |
+
"""Rewrite every call site that exactly matches from_primitive."""
|
| 898 |
+
|
| 899 |
+
def leave_Call(self, original_node: cst.Call, updated_node: cst.Call) -> cst.BaseExpression:
|
| 900 |
+
# Use EXACT full dotted name comparison, not substring.
|
| 901 |
+
full_name = _cst_name(updated_node.func)
|
| 902 |
+
if full_name != from_primitive:
|
| 903 |
+
return updated_node
|
| 904 |
+
try:
|
| 905 |
+
replacement_func = cst.parse_expression(to_primitive)
|
| 906 |
+
return updated_node.with_changes(func=replacement_func)
|
| 907 |
+
except Exception:
|
| 908 |
+
return updated_node
|
| 909 |
+
|
| 910 |
+
class ImportRewriter(cst.CSTTransformer):
|
| 911 |
+
"""
|
| 912 |
+
Rewrite import statements to match the new primitive's module.
|
| 913 |
+
|
| 914 |
+
Handles all four patterns:
|
| 915 |
+
import threading → also imports to_module
|
| 916 |
+
from threading import Lock → from to_module import to_class
|
| 917 |
+
from threading import Lock as L → from to_module import to_class as L
|
| 918 |
+
import threading as thr → also imports to_module as its alias
|
| 919 |
+
"""
|
| 920 |
+
|
| 921 |
+
def leave_ImportFrom(
|
| 922 |
+
self, original_node: cst.ImportFrom, updated_node: cst.ImportFrom
|
| 923 |
+
) -> cst.ImportFrom:
|
| 924 |
+
if from_module and to_module and from_module != to_module:
|
| 925 |
+
# Get the module name as a string safely
|
| 926 |
+
try:
|
| 927 |
+
mod_str = cst.parse_module("").code_for_node(updated_node.module) if updated_node.module else ""
|
| 928 |
+
except Exception:
|
| 929 |
+
mod_str = ""
|
| 930 |
+
if mod_str == from_module:
|
| 931 |
+
new_module = cst.parse_expression(to_module)
|
| 932 |
+
# Rewrite the imported name
|
| 933 |
+
new_names: List[cst.ImportAlias] = []
|
| 934 |
+
for alias_node in (updated_node.names if isinstance(updated_node.names, (list, tuple)) else []):
|
| 935 |
+
if isinstance(alias_node, cst.ImportAlias):
|
| 936 |
+
name_str = alias_node.name.value if isinstance(alias_node.name, cst.Name) else ""
|
| 937 |
+
from_class = from_primitive.rsplit(".", 1)[-1]
|
| 938 |
+
if name_str == from_class:
|
| 939 |
+
new_alias = alias_node.with_changes(
|
| 940 |
+
name=cst.Name(to_class)
|
| 941 |
+
)
|
| 942 |
+
new_names.append(new_alias)
|
| 943 |
+
else:
|
| 944 |
+
new_names.append(alias_node)
|
| 945 |
+
else:
|
| 946 |
+
pass # skip star imports
|
| 947 |
+
return updated_node.with_changes(
|
| 948 |
+
module=new_module,
|
| 949 |
+
names=new_names if new_names else updated_node.names,
|
| 950 |
+
)
|
| 951 |
+
return updated_node
|
| 952 |
+
|
| 953 |
+
# Step 1: swap all call sites
|
| 954 |
+
transformed = module.visit(PrimitiveSwapper()).code
|
| 955 |
+
# Step 2: rewrite imports
|
| 956 |
+
transformed_module = cst.parse_module(transformed)
|
| 957 |
+
transformed = transformed_module.visit(ImportRewriter()).code
|
| 958 |
+
# Step 3: ensure the new primitive's module is imported if it isn't already
|
| 959 |
+
if to_module and to_module not in transformed.split(".")[0]:
|
| 960 |
+
if f"import {to_module}" not in transformed and f"from {to_module}" not in transformed:
|
| 961 |
+
transformed = f"import {to_module}\n" + transformed
|
| 962 |
+
return transformed
|
| 963 |
+
|
| 964 |
+
|
| 965 |
+
def _apply_isolate_boundary(source: str, target: Dict[str, Any]) -> str:
|
| 966 |
+
"""Wrap external call in circuit-breaker / timeout."""
|
| 967 |
+
boundary_call = str(target.get("boundary_call", ""))
|
| 968 |
+
pattern = str(target.get("pattern", "timeout"))
|
| 969 |
+
|
| 970 |
+
module = cst.parse_module(source)
|
| 971 |
+
|
| 972 |
+
class BoundaryIsolator(cst.CSTTransformer):
|
| 973 |
+
def leave_Call(self, original_node: cst.Call, updated_node: cst.Call) -> cst.BaseExpression:
|
| 974 |
+
name = _cst_name(updated_node.func)
|
| 975 |
+
if boundary_call and boundary_call in (name or ""):
|
| 976 |
+
try:
|
| 977 |
+
if pattern == "timeout":
|
| 978 |
+
wrapper = cst.parse_expression(f"asyncio.wait_for({name}(), timeout=5.0)")
|
| 979 |
+
elif pattern == "circuit_breaker":
|
| 980 |
+
wrapper = cst.parse_expression(f"_circuit_breaker({name})")
|
| 981 |
+
else:
|
| 982 |
+
return updated_node
|
| 983 |
+
return wrapper
|
| 984 |
+
except Exception:
|
| 985 |
+
pass
|
| 986 |
+
return updated_node
|
| 987 |
+
|
| 988 |
+
return module.visit(BoundaryIsolator()).code
|
| 989 |
+
|
| 990 |
+
|
| 991 |
+
def _apply_extract_async_scope(source: str, target: Dict[str, Any]) -> str:
|
| 992 |
+
"""
|
| 993 |
+
Structurally fix the async/sync boundary problem.
|
| 994 |
+
|
| 995 |
+
direction='sync_to_async' (most common case):
|
| 996 |
+
Converts a synchronous function to async AND wraps every detected
|
| 997 |
+
blocking I/O call (DB / HTTP / socket) with ``asyncio.to_thread()``
|
| 998 |
+
so the event loop is never stalled.
|
| 999 |
+
This is the correct fix for: ``async def f(): db.commit()``
|
| 1000 |
+
|
| 1001 |
+
direction='async_to_sync':
|
| 1002 |
+
Removes the async/await keywords so the function can be called
|
| 1003 |
+
safely from synchronous contexts.
|
| 1004 |
+
|
| 1005 |
+
Previous implementation only flipped the `async def` keyword but left
|
| 1006 |
+
blocking calls inside untouched — which made things WORSE.
|
| 1007 |
+
"""
|
| 1008 |
+
from typing import Set as _Set
|
| 1009 |
+
|
| 1010 |
+
fn_name = str(target.get("function_name", ""))
|
| 1011 |
+
direction = str(target.get("direction", "sync_to_async"))
|
| 1012 |
+
|
| 1013 |
+
# Blocking signatures reused from causal_graph detection sets
|
| 1014 |
+
BLOCKING_CALL_SIGNATURES: _Set[str] = {
|
| 1015 |
+
# DB
|
| 1016 |
+
"session.commit", "session.execute", "session.add", "session.flush",
|
| 1017 |
+
"cursor.execute", "cursor.executemany",
|
| 1018 |
+
"db.commit", "db.execute", "db.session.commit",
|
| 1019 |
+
"engine.connect", "engine.execute",
|
| 1020 |
+
"collection.find", "collection.insert_one", "collection.update_one",
|
| 1021 |
+
"redis.set", "redis.get",
|
| 1022 |
+
# HTTP
|
| 1023 |
+
"requests.get", "requests.post", "requests.put",
|
| 1024 |
+
"requests.delete", "requests.patch", "urllib.request.urlopen",
|
| 1025 |
+
# Filesystem / socket blocking
|
| 1026 |
+
"socket.recv", "socket.accept",
|
| 1027 |
+
# time.sleep is a special case — always blocking
|
| 1028 |
+
"time.sleep",
|
| 1029 |
+
}
|
| 1030 |
+
# Partial prefixes that should be wrapped regardless of full name
|
| 1031 |
+
BLOCKING_PREFIXES = (
|
| 1032 |
+
"requests.", "urllib.request.", "socket.", "cursor.",
|
| 1033 |
+
"session.", "db.", "engine.", "collection.", "redis.",
|
| 1034 |
+
)
|
| 1035 |
+
|
| 1036 |
+
module = cst.parse_module(source)
|
| 1037 |
+
|
| 1038 |
+
class AsyncScopeExtractor(cst.CSTTransformer):
|
| 1039 |
+
def _is_target_function(self, node_name: str) -> bool:
|
| 1040 |
+
return (not fn_name) or (fn_name in node_name)
|
| 1041 |
+
|
| 1042 |
+
def _call_is_blocking(self, call_node: cst.Call) -> bool:
|
| 1043 |
+
full = _cst_name(call_node.func)
|
| 1044 |
+
return (
|
| 1045 |
+
full in BLOCKING_CALL_SIGNATURES
|
| 1046 |
+
or any(full.startswith(pfx) for pfx in BLOCKING_PREFIXES)
|
| 1047 |
+
)
|
| 1048 |
+
|
| 1049 |
+
# ── sync_to_async: make function async + wrap blocking calls ──────────
|
| 1050 |
+
def leave_FunctionDef(
|
| 1051 |
+
self, original_node: cst.FunctionDef, updated_node: cst.FunctionDef
|
| 1052 |
+
) -> cst.FunctionDef:
|
| 1053 |
+
if direction != "sync_to_async":
|
| 1054 |
+
return updated_node
|
| 1055 |
+
if not self._is_target_function(original_node.name.value):
|
| 1056 |
+
return updated_node
|
| 1057 |
+
if original_node.asynchronous:
|
| 1058 |
+
# Already async — just wrap blocking calls inside
|
| 1059 |
+
return updated_node
|
| 1060 |
+
return updated_node.with_changes(
|
| 1061 |
+
asynchronous=cst.Asynchronous(),
|
| 1062 |
+
)
|
| 1063 |
+
|
| 1064 |
+
def leave_Call(
|
| 1065 |
+
self, original_node: cst.Call, updated_node: cst.Call
|
| 1066 |
+
) -> cst.BaseExpression:
|
| 1067 |
+
"""Wrap blocking calls inside the target function with asyncio.to_thread()."""
|
| 1068 |
+
if direction != "sync_to_async":
|
| 1069 |
+
return updated_node
|
| 1070 |
+
if not self._call_is_blocking(updated_node):
|
| 1071 |
+
return updated_node
|
| 1072 |
+
# Build: await asyncio.to_thread(original_call)
|
| 1073 |
+
# The `await` keyword is added by the parent Await node;
|
| 1074 |
+
# here we just wrap the call in asyncio.to_thread(...).
|
| 1075 |
+
try:
|
| 1076 |
+
wrapped = cst.parse_expression(
|
| 1077 |
+
f"asyncio.to_thread({_cst_name(updated_node.func)})"
|
| 1078 |
+
)
|
| 1079 |
+
# Preserve original arguments inside to_thread
|
| 1080 |
+
if isinstance(wrapped, cst.Call) and updated_node.args:
|
| 1081 |
+
all_args = list(wrapped.args) + list(updated_node.args)
|
| 1082 |
+
wrapped = wrapped.with_changes(args=all_args)
|
| 1083 |
+
return wrapped
|
| 1084 |
+
except Exception:
|
| 1085 |
+
return updated_node
|
| 1086 |
+
|
| 1087 |
+
# ── async_to_sync: strip async/await ──────────────────────────────────
|
| 1088 |
+
def leave_AsyncFunctionDef(
|
| 1089 |
+
self, original_node: cst.AsyncFunctionDef, updated_node: cst.AsyncFunctionDef
|
| 1090 |
+
) -> cst.FunctionDef:
|
| 1091 |
+
if direction != "async_to_sync":
|
| 1092 |
+
return updated_node # type: ignore[return-value]
|
| 1093 |
+
if not self._is_target_function(original_node.name.value):
|
| 1094 |
+
return updated_node # type: ignore[return-value]
|
| 1095 |
+
# Convert AsyncFunctionDef → FunctionDef
|
| 1096 |
+
sync_fn = cst.FunctionDef(
|
| 1097 |
+
name=updated_node.name,
|
| 1098 |
+
params=updated_node.params,
|
| 1099 |
+
body=updated_node.body,
|
| 1100 |
+
decorators=updated_node.decorators,
|
| 1101 |
+
returns=updated_node.returns,
|
| 1102 |
+
leading_lines=updated_node.leading_lines,
|
| 1103 |
+
lines_after_decorators=updated_node.lines_after_decorators,
|
| 1104 |
+
)
|
| 1105 |
+
return sync_fn
|
| 1106 |
+
|
| 1107 |
+
transformed = module.visit(AsyncScopeExtractor()).code
|
| 1108 |
+
# Ensure asyncio is imported when doing sync_to_async
|
| 1109 |
+
if direction == "sync_to_async" and "import asyncio" not in transformed:
|
| 1110 |
+
transformed = "import asyncio\n" + transformed
|
| 1111 |
+
return transformed
|
| 1112 |
+
|
| 1113 |
+
|
| 1114 |
+
def _apply_harden_idempotency(source: str, target: Dict[str, Any]) -> str:
|
| 1115 |
+
"""Add idempotency guard to a state-mutating function."""
|
| 1116 |
+
state_target = str(target.get("state_target", ""))
|
| 1117 |
+
key_strategy = str(target.get("key_strategy", "uuid"))
|
| 1118 |
+
|
| 1119 |
+
module = cst.parse_module(source)
|
| 1120 |
+
idempotency_guard = (
|
| 1121 |
+
"\n"
|
| 1122 |
+
"def _ensure_idempotency(key_strategy='uuid'):\n"
|
| 1123 |
+
" import uuid, hashlib\n"
|
| 1124 |
+
" if key_strategy == 'uuid':\n"
|
| 1125 |
+
" return str(uuid.uuid4())\n"
|
| 1126 |
+
" return hashlib.md5(str(key_strategy).encode()).hexdigest()\n"
|
| 1127 |
+
)
|
| 1128 |
+
|
| 1129 |
+
class IdempotencyHardener(cst.CSTTransformer):
|
| 1130 |
+
def leave_FunctionDef(self, original_node: cst.FunctionDef, updated_node: cst.FunctionDef) -> cst.FunctionDef:
|
| 1131 |
+
if state_target and state_target not in original_node.name.value:
|
| 1132 |
+
return updated_node
|
| 1133 |
+
body = list(updated_node.body.body)
|
| 1134 |
+
guard_stmt = cst.parse_statement("_idempotency_key = _ensure_idempotency()\n")
|
| 1135 |
+
if any(getattr(s, "code", "").find("_idempotency_key") >= 0 for s in body):
|
| 1136 |
+
return updated_node
|
| 1137 |
+
return updated_node.with_changes(body=updated_node.body.with_changes(body=[guard_stmt, *body]))
|
| 1138 |
+
|
| 1139 |
+
transformed = module.visit(IdempotencyHardener()).code
|
| 1140 |
+
if "def _ensure_idempotency" not in transformed:
|
| 1141 |
+
transformed = transformed + idempotency_guard
|
| 1142 |
+
return transformed
|
| 1143 |
+
|
| 1144 |
+
|
| 1145 |
+
def _parse_js_ts_summary(path: Path) -> ASTSummary:
|
| 1146 |
+
source = path.read_text(encoding="utf-8", errors="ignore")
|
| 1147 |
+
try:
|
| 1148 |
+
from tree_sitter import Language, Parser
|
| 1149 |
+
import tree_sitter_javascript as ts_js
|
| 1150 |
+
except Exception:
|
| 1151 |
+
return ASTSummary([], [], [], [], [], [])
|
| 1152 |
+
|
| 1153 |
+
parser = Parser()
|
| 1154 |
+
language = Language(ts_js.language())
|
| 1155 |
+
parser.language = language
|
| 1156 |
+
tree = parser.parse(bytes(source, "utf-8"))
|
| 1157 |
+
|
| 1158 |
+
functions: List[Dict[str, Any]] = []
|
| 1159 |
+
classes: List[Dict[str, Any]] = []
|
| 1160 |
+
imports: List[str] = []
|
| 1161 |
+
external_calls: List[str] = []
|
| 1162 |
+
|
| 1163 |
+
stack = [tree.root_node]
|
| 1164 |
+
while stack:
|
| 1165 |
+
node = stack.pop()
|
| 1166 |
+
stack.extend(node.children)
|
| 1167 |
+
|
| 1168 |
+
if node.type in {"function_declaration", "method_definition", "arrow_function"}:
|
| 1169 |
+
name = "<anonymous>"
|
| 1170 |
+
for child in node.children:
|
| 1171 |
+
if child.type in {"identifier", "property_identifier"}:
|
| 1172 |
+
name = source[child.start_byte : child.end_byte]
|
| 1173 |
+
break
|
| 1174 |
+
functions.append(
|
| 1175 |
+
{
|
| 1176 |
+
"name": name,
|
| 1177 |
+
"is_async": "async" in source[node.start_byte : node.end_byte].split("(", 1)[0],
|
| 1178 |
+
"args": [],
|
| 1179 |
+
"decorators": [],
|
| 1180 |
+
"line_range": [node.start_point[0] + 1, node.end_point[0] + 1],
|
| 1181 |
+
}
|
| 1182 |
+
)
|
| 1183 |
+
|
| 1184 |
+
if node.type == "class_declaration":
|
| 1185 |
+
class_name = "<anonymous_class>"
|
| 1186 |
+
for child in node.children:
|
| 1187 |
+
if child.type == "identifier":
|
| 1188 |
+
class_name = source[child.start_byte : child.end_byte]
|
| 1189 |
+
break
|
| 1190 |
+
classes.append(
|
| 1191 |
+
{
|
| 1192 |
+
"name": class_name,
|
| 1193 |
+
"bases": [],
|
| 1194 |
+
"decorators": [],
|
| 1195 |
+
"line_range": [node.start_point[0] + 1, node.end_point[0] + 1],
|
| 1196 |
+
}
|
| 1197 |
+
)
|
| 1198 |
+
|
| 1199 |
+
if node.type == "import_statement":
|
| 1200 |
+
imports.append(source[node.start_byte : node.end_byte].strip())
|
| 1201 |
+
|
| 1202 |
+
if node.type == "call_expression":
|
| 1203 |
+
snippet = source[node.start_byte : node.end_byte]
|
| 1204 |
+
if any(mod in snippet for mod in ["axios", "fetch", "redis", "pg."]):
|
| 1205 |
+
external_calls.append(snippet[:120])
|
| 1206 |
+
|
| 1207 |
+
return ASTSummary(
|
| 1208 |
+
functions=functions,
|
| 1209 |
+
classes=classes,
|
| 1210 |
+
imports=sorted(set(imports)),
|
| 1211 |
+
global_vars=[],
|
| 1212 |
+
threading_primitives=[],
|
| 1213 |
+
external_calls=sorted(set(external_calls)),
|
| 1214 |
+
)
|
templates/index.html
ADDED
|
@@ -0,0 +1,1126 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html lang="en">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="UTF-8"/>
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
| 6 |
+
<title>FlakeForge — RL Agent for Flaky Test Repair</title>
|
| 7 |
+
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800;900&family=JetBrains+Mono:wght@300;400;500;700&display=swap" rel="stylesheet"/>
|
| 8 |
+
<style>
|
| 9 |
+
:root {
|
| 10 |
+
--bg: #09090b;
|
| 11 |
+
--bg2: #0f0f12;
|
| 12 |
+
--bg3: #16161a;
|
| 13 |
+
--bg4: #1c1c22;
|
| 14 |
+
--amber: #f59e0b;
|
| 15 |
+
--amber2: #fbbf24;
|
| 16 |
+
--amber-dim: rgba(245,158,11,.08);
|
| 17 |
+
--amber-glow: rgba(245,158,11,.15);
|
| 18 |
+
--teal: #06b6d4;
|
| 19 |
+
--red: #ef4444;
|
| 20 |
+
--green: #22c55e;
|
| 21 |
+
--purple: #a78bfa;
|
| 22 |
+
--text: #fafafa;
|
| 23 |
+
--text2: #e4e4e7;
|
| 24 |
+
--dim: #a1a1aa;
|
| 25 |
+
--muted: #52525b;
|
| 26 |
+
--border: rgba(255,255,255,.06);
|
| 27 |
+
--border2: rgba(255,255,255,.1);
|
| 28 |
+
--radius: 12px;
|
| 29 |
+
}
|
| 30 |
+
* { margin: 0; padding: 0; box-sizing: border-box; }
|
| 31 |
+
html { scroll-behavior: smooth; }
|
| 32 |
+
body { background: var(--bg); color: var(--text); font-family: 'Inter', -apple-system, sans-serif; overflow-x: hidden; -webkit-font-smoothing: antialiased; }
|
| 33 |
+
|
| 34 |
+
/* NAV */
|
| 35 |
+
nav {
|
| 36 |
+
position: fixed; top: 0; left: 0; right: 0; z-index: 200;
|
| 37 |
+
display: flex; align-items: center; justify-content: space-between;
|
| 38 |
+
padding: 0 2rem; height: 56px;
|
| 39 |
+
background: rgba(9,9,11,.85); backdrop-filter: blur(24px) saturate(1.4);
|
| 40 |
+
border-bottom: 1px solid var(--border);
|
| 41 |
+
}
|
| 42 |
+
.logo { display: flex; align-items: center; gap: .45rem; font-weight: 800; font-size: 1.05rem; letter-spacing: -.03em; }
|
| 43 |
+
.logo em { color: var(--amber); font-style: normal; }
|
| 44 |
+
.nav-links { display: flex; gap: 2rem; }
|
| 45 |
+
.nav-links a { color: var(--muted); text-decoration: none; font-size: .8rem; font-weight: 500; transition: color .2s; }
|
| 46 |
+
.nav-links a:hover { color: var(--text); }
|
| 47 |
+
.nav-badge { background: var(--amber-dim); border: 1px solid rgba(245,158,11,.2); color: var(--amber); padding: .3rem .75rem; border-radius: 100px; font-family: 'JetBrains Mono', monospace; font-size: .6rem; font-weight: 600; letter-spacing: .08em; text-transform: uppercase; }
|
| 48 |
+
|
| 49 |
+
/* HERO */
|
| 50 |
+
.hero {
|
| 51 |
+
min-height: 100vh; display: flex; flex-direction: column;
|
| 52 |
+
align-items: center; justify-content: center;
|
| 53 |
+
padding: 7rem 2rem 5rem; position: relative; text-align: center;
|
| 54 |
+
}
|
| 55 |
+
.hero-glow {
|
| 56 |
+
position: absolute; top: 20%; left: 50%; transform: translate(-50%, -50%);
|
| 57 |
+
width: 700px; height: 400px; border-radius: 50%;
|
| 58 |
+
background: radial-gradient(ellipse, rgba(245,158,11,.06) 0%, transparent 65%);
|
| 59 |
+
pointer-events: none;
|
| 60 |
+
}
|
| 61 |
+
.hero-eyebrow {
|
| 62 |
+
font-family: 'JetBrains Mono', monospace; font-size: .7rem;
|
| 63 |
+
letter-spacing: .15em; color: var(--amber); text-transform: uppercase;
|
| 64 |
+
margin-bottom: 1.5rem; opacity: 0; animation: fadeUp .5s .1s ease forwards;
|
| 65 |
+
}
|
| 66 |
+
h1.hero-title {
|
| 67 |
+
font-size: clamp(2.6rem, 5.5vw, 4.5rem); font-weight: 900;
|
| 68 |
+
line-height: 1.05; letter-spacing: -.045em;
|
| 69 |
+
opacity: 0; animation: fadeUp .5s .2s ease forwards;
|
| 70 |
+
}
|
| 71 |
+
h1.hero-title em { color: var(--amber); font-style: normal; }
|
| 72 |
+
.hero-sub {
|
| 73 |
+
max-width: 520px; color: var(--dim); font-size: .95rem;
|
| 74 |
+
line-height: 1.7; margin-top: 1.2rem; font-weight: 400;
|
| 75 |
+
opacity: 0; animation: fadeUp .5s .3s ease forwards;
|
| 76 |
+
}
|
| 77 |
+
.hero-actions {
|
| 78 |
+
display: flex; gap: .75rem; margin-top: 2rem;
|
| 79 |
+
opacity: 0; animation: fadeUp .5s .4s ease forwards;
|
| 80 |
+
}
|
| 81 |
+
.btn-primary {
|
| 82 |
+
background: var(--amber); color: #000; border: none;
|
| 83 |
+
padding: .7rem 1.6rem; border-radius: 8px;
|
| 84 |
+
font-family: 'Inter', sans-serif; font-weight: 700; font-size: .85rem;
|
| 85 |
+
cursor: pointer; transition: all .2s;
|
| 86 |
+
}
|
| 87 |
+
.btn-primary:hover { background: var(--amber2); transform: translateY(-1px); box-shadow: 0 8px 32px var(--amber-glow); }
|
| 88 |
+
.btn-ghost {
|
| 89 |
+
background: transparent; color: var(--text2); border: 1px solid var(--border2);
|
| 90 |
+
padding: .7rem 1.6rem; border-radius: 8px;
|
| 91 |
+
font-family: 'Inter', sans-serif; font-weight: 600; font-size: .85rem;
|
| 92 |
+
cursor: pointer; transition: all .2s;
|
| 93 |
+
}
|
| 94 |
+
.btn-ghost:hover { border-color: var(--amber); color: var(--amber); }
|
| 95 |
+
|
| 96 |
+
/* HERO TERMINAL */
|
| 97 |
+
.hero-terminal {
|
| 98 |
+
width: 100%; max-width: 780px; margin-top: 3.5rem;
|
| 99 |
+
background: var(--bg2); border: 1px solid var(--border2); border-radius: var(--radius);
|
| 100 |
+
overflow: hidden; opacity: 0; animation: fadeUp .6s .5s ease forwards;
|
| 101 |
+
box-shadow: 0 32px 64px rgba(0,0,0,.4);
|
| 102 |
+
}
|
| 103 |
+
.term-bar {
|
| 104 |
+
display: flex; align-items: center; gap: .45rem;
|
| 105 |
+
padding: .6rem 1rem; background: var(--bg3); border-bottom: 1px solid var(--border);
|
| 106 |
+
}
|
| 107 |
+
.tdot { width: 10px; height: 10px; border-radius: 50%; }
|
| 108 |
+
.tdot.r { background: #ef4444; } .tdot.y { background: #f59e0b; } .tdot.g { background: #22c55e; }
|
| 109 |
+
.term-title { margin-left: .5rem; font-family: 'JetBrains Mono', monospace; font-size: .68rem; color: var(--muted); }
|
| 110 |
+
.term-body { display: grid; grid-template-columns: 1fr 1fr; }
|
| 111 |
+
.term-col {
|
| 112 |
+
padding: 1.2rem 1.4rem; font-family: 'JetBrains Mono', monospace;
|
| 113 |
+
font-size: .72rem; line-height: 1.9;
|
| 114 |
+
}
|
| 115 |
+
.term-col:first-child { border-right: 1px solid var(--border); }
|
| 116 |
+
.term-col-label {
|
| 117 |
+
font-size: .58rem; letter-spacing: .14em; text-transform: uppercase;
|
| 118 |
+
color: var(--muted); margin-bottom: .7rem; padding-bottom: .45rem; border-bottom: 1px solid var(--border);
|
| 119 |
+
}
|
| 120 |
+
.c-red { color: var(--red); } .c-green { color: var(--green); } .c-amber { color: var(--amber); }
|
| 121 |
+
.c-teal { color: var(--teal); } .c-purple { color: var(--purple); } .c-dim { color: var(--muted); }
|
| 122 |
+
.flicker { animation: flk 2.2s infinite; }
|
| 123 |
+
@keyframes flk { 0%,48% { color: var(--red); } 50%,98% { color: var(--green); } 100% { color: var(--red); } }
|
| 124 |
+
|
| 125 |
+
/* STATS ROW */
|
| 126 |
+
.stats-row {
|
| 127 |
+
display: grid; grid-template-columns: repeat(4, 1fr);
|
| 128 |
+
border-top: 1px solid var(--border); border-bottom: 1px solid var(--border);
|
| 129 |
+
}
|
| 130 |
+
.stat-cell {
|
| 131 |
+
padding: 2.5rem 2rem; text-align: center;
|
| 132 |
+
border-right: 1px solid var(--border);
|
| 133 |
+
}
|
| 134 |
+
.stat-cell:last-child { border-right: none; }
|
| 135 |
+
.stat-val { font-size: 2.4rem; font-weight: 900; color: var(--amber); letter-spacing: -.04em; line-height: 1; }
|
| 136 |
+
.stat-lbl {
|
| 137 |
+
font-family: 'JetBrains Mono', monospace; font-size: .62rem;
|
| 138 |
+
color: var(--muted); margin-top: .5rem; letter-spacing: .05em; text-transform: uppercase;
|
| 139 |
+
}
|
| 140 |
+
|
| 141 |
+
/* SECTIONS */
|
| 142 |
+
section { padding: 6rem 2rem; }
|
| 143 |
+
.sec-inner { max-width: 1100px; margin: 0 auto; }
|
| 144 |
+
.sec-label {
|
| 145 |
+
font-family: 'JetBrains Mono', monospace; font-size: .65rem;
|
| 146 |
+
letter-spacing: .18em; color: var(--amber); text-transform: uppercase;
|
| 147 |
+
margin-bottom: .6rem;
|
| 148 |
+
}
|
| 149 |
+
.sec-title { font-size: clamp(1.6rem, 2.8vw, 2.2rem); font-weight: 800; letter-spacing: -.03em; line-height: 1.15; }
|
| 150 |
+
.sec-desc { color: var(--dim); font-size: .9rem; line-height: 1.7; margin-top: .7rem; max-width: 520px; }
|
| 151 |
+
.sec-header { margin-bottom: 3.5rem; }
|
| 152 |
+
|
| 153 |
+
/* REVEAL */
|
| 154 |
+
.reveal { opacity: 0; transform: translateY(20px); transition: opacity .6s ease, transform .6s ease; }
|
| 155 |
+
.reveal.on { opacity: 1; transform: translateY(0); }
|
| 156 |
+
|
| 157 |
+
/* HOW IT WORKS */
|
| 158 |
+
.steps-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 1.5rem; }
|
| 159 |
+
.step-card {
|
| 160 |
+
background: var(--bg2); border: 1px solid var(--border);
|
| 161 |
+
border-radius: var(--radius); padding: 2rem 1.8rem;
|
| 162 |
+
transition: border-color .3s;
|
| 163 |
+
}
|
| 164 |
+
.step-card:hover { border-color: var(--border2); }
|
| 165 |
+
.step-num {
|
| 166 |
+
font-family: 'JetBrains Mono', monospace; font-size: .6rem;
|
| 167 |
+
color: var(--amber); letter-spacing: .12em; text-transform: uppercase;
|
| 168 |
+
margin-bottom: 1rem;
|
| 169 |
+
}
|
| 170 |
+
.step-icon { font-size: 1.6rem; margin-bottom: 1rem; }
|
| 171 |
+
.step-card h3 { font-size: 1rem; font-weight: 700; margin-bottom: .5rem; }
|
| 172 |
+
.step-card p { font-size: .82rem; color: var(--dim); line-height: 1.65; }
|
| 173 |
+
|
| 174 |
+
/* EPISODE SECTION */
|
| 175 |
+
.ep-section { background: var(--bg2); border-top: 1px solid var(--border); border-bottom: 1px solid var(--border); }
|
| 176 |
+
.run-controls { display: flex; align-items: center; gap: 1rem; margin-bottom: 2.5rem; }
|
| 177 |
+
.run-btn {
|
| 178 |
+
background: var(--amber); color: #000; border: none;
|
| 179 |
+
padding: .65rem 1.5rem; border-radius: 8px;
|
| 180 |
+
font-family: 'Inter', sans-serif; font-weight: 700; font-size: .82rem;
|
| 181 |
+
cursor: pointer; transition: all .2s;
|
| 182 |
+
}
|
| 183 |
+
.run-btn:hover:not(:disabled) { background: var(--amber2); transform: translateY(-1px); box-shadow: 0 6px 24px var(--amber-glow); }
|
| 184 |
+
.run-btn:disabled { opacity: .4; cursor: not-allowed; }
|
| 185 |
+
.ep-status { font-family: 'JetBrains Mono', monospace; font-size: .72rem; color: var(--muted); }
|
| 186 |
+
.ep-status em { color: var(--amber); font-style: normal; }
|
| 187 |
+
|
| 188 |
+
/* EPISODE GRID — wide, clean 2-col */
|
| 189 |
+
.ep-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 1.5rem; }
|
| 190 |
+
|
| 191 |
+
/* PANEL SHARED */
|
| 192 |
+
.panel {
|
| 193 |
+
background: var(--bg3); border: 1px solid var(--border);
|
| 194 |
+
border-radius: var(--radius); overflow: hidden;
|
| 195 |
+
}
|
| 196 |
+
.panel-head {
|
| 197 |
+
display: flex; align-items: center; justify-content: space-between;
|
| 198 |
+
padding: .55rem 1rem; background: rgba(255,255,255,.02);
|
| 199 |
+
border-bottom: 1px solid var(--border);
|
| 200 |
+
font-family: 'JetBrains Mono', monospace; font-size: .68rem; color: var(--muted);
|
| 201 |
+
}
|
| 202 |
+
.panel-head-tag { font-size: .58rem; letter-spacing: .1em; text-transform: uppercase; }
|
| 203 |
+
.panel-body {
|
| 204 |
+
padding: 1rem; font-family: 'JetBrains Mono', monospace;
|
| 205 |
+
font-size: .72rem; line-height: 1.85;
|
| 206 |
+
}
|
| 207 |
+
.panel-body::-webkit-scrollbar { width: 3px; } .panel-body::-webkit-scrollbar-thumb { background: var(--border2); }
|
| 208 |
+
|
| 209 |
+
/* CODE PANEL */
|
| 210 |
+
.hi-line {
|
| 211 |
+
display: block; background: rgba(239,68,68,.08);
|
| 212 |
+
border-left: 2px solid var(--red); padding-left: .5rem;
|
| 213 |
+
animation: pulse-r 2s infinite;
|
| 214 |
+
}
|
| 215 |
+
@keyframes pulse-r { 0%,100% { background: rgba(239,68,68,.06); } 50% { background: rgba(239,68,68,.15); } }
|
| 216 |
+
.patch-line { display: block; opacity: 0; transform: translateX(-6px); transition: opacity .25s, transform .25s; }
|
| 217 |
+
.patch-line.show { opacity: 1; transform: translateX(0); }
|
| 218 |
+
.p-add { border-left: 2px solid var(--green); padding-left: .5rem; color: var(--green); }
|
| 219 |
+
.p-rem { border-left: 2px solid var(--red); padding-left: .5rem; color: var(--red); text-decoration: line-through; opacity: .6; }
|
| 220 |
+
.p-neu { color: var(--dim); }
|
| 221 |
+
|
| 222 |
+
/* CAUSAL GRAPH */
|
| 223 |
+
canvas#cgCanvas { display: block; width: 100%; height: 220px; }
|
| 224 |
+
|
| 225 |
+
/* ACTION GRID */
|
| 226 |
+
.action-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: .45rem; padding: 1rem; }
|
| 227 |
+
.action-card {
|
| 228 |
+
background: var(--bg); border: 1px solid var(--border); border-radius: 8px;
|
| 229 |
+
padding: .55rem .5rem; font-family: 'JetBrains Mono', monospace;
|
| 230 |
+
font-size: .58rem; color: var(--muted); text-align: center; cursor: default;
|
| 231 |
+
transition: all .3s;
|
| 232 |
+
}
|
| 233 |
+
.action-card .ac-icon { font-size: 1rem; margin-bottom: .25rem; }
|
| 234 |
+
.action-card .ac-name { font-size: .55rem; letter-spacing: .04em; text-transform: uppercase; display: block; }
|
| 235 |
+
.action-card.considering { border-color: rgba(245,158,11,.4); background: rgba(245,158,11,.04); color: var(--dim); animation: consider-pulse .6s ease infinite alternate; }
|
| 236 |
+
@keyframes consider-pulse { 0% { border-color: rgba(245,158,11,.3); } 100% { border-color: rgba(245,158,11,.7); } }
|
| 237 |
+
.action-card.selected { border-color: var(--amber); background: rgba(245,158,11,.1); color: var(--amber); box-shadow: 0 0 16px var(--amber-glow); }
|
| 238 |
+
.action-card.rejected { opacity: .2; }
|
| 239 |
+
|
| 240 |
+
/* REWARD BARS */
|
| 241 |
+
.reward-bars { padding: 1rem; display: flex; flex-direction: column; gap: .45rem; }
|
| 242 |
+
.rbar-row { display: flex; align-items: center; gap: .6rem; }
|
| 243 |
+
.rbar-label { font-family: 'JetBrains Mono', monospace; font-size: .6rem; color: var(--muted); width: 130px; flex-shrink: 0; text-align: right; }
|
| 244 |
+
.rbar-track { flex: 1; height: 14px; background: var(--bg); border-radius: 4px; overflow: hidden; }
|
| 245 |
+
.rbar-fill { height: 100%; width: 0%; border-radius: 4px; transition: width .6s cubic-bezier(.22,1,.36,1); }
|
| 246 |
+
.rbar-fill.pos { background: linear-gradient(90deg, rgba(34,197,94,.35), var(--green)); }
|
| 247 |
+
.rbar-fill.neg { background: linear-gradient(90deg, rgba(239,68,68,.35), var(--red)); float: right; }
|
| 248 |
+
.rbar-val { font-family: 'JetBrains Mono', monospace; font-size: .58rem; width: 45px; flex-shrink: 0; color: var(--dim); }
|
| 249 |
+
.rbar-val.pos { color: var(--green); } .rbar-val.neg { color: var(--red); }
|
| 250 |
+
.reward-total {
|
| 251 |
+
display: flex; justify-content: space-between; align-items: center;
|
| 252 |
+
padding: .65rem 1rem; border-top: 1px solid var(--border);
|
| 253 |
+
font-family: 'JetBrains Mono', monospace; font-size: .75rem; font-weight: 700;
|
| 254 |
+
}
|
| 255 |
+
.reward-total-val { color: var(--amber); font-size: .95rem; }
|
| 256 |
+
|
| 257 |
+
/* THINK STREAM */
|
| 258 |
+
.think-body { min-height: 80px; max-height: 160px; overflow-y: auto; }
|
| 259 |
+
.th { color: var(--amber); background: rgba(245,158,11,.08); padding: 0 4px; border-radius: 3px; font-weight: 700; }
|
| 260 |
+
.think-cursor { display: inline-block; width: 2px; height: .85em; background: var(--amber); vertical-align: middle; margin-left: 1px; animation: blink .8s step-end infinite; }
|
| 261 |
+
@keyframes blink { 0%,100% { opacity: 1; } 50% { opacity: 0; } }
|
| 262 |
+
|
| 263 |
+
/* TIMELINE */
|
| 264 |
+
.timeline-body { padding: 1rem; display: flex; gap: .5rem; align-items: flex-end; min-height: 60px; overflow-x: auto; }
|
| 265 |
+
.timeline-body::-webkit-scrollbar { height: 3px; } .timeline-body::-webkit-scrollbar-thumb { background: var(--border2); }
|
| 266 |
+
.tl-step { display: flex; flex-direction: column; align-items: center; gap: .25rem; flex-shrink: 0; opacity: 0; transform: translateY(8px); transition: all .3s; }
|
| 267 |
+
.tl-step.show { opacity: 1; transform: translateY(0); }
|
| 268 |
+
.tl-bar { width: 26px; border-radius: 3px 3px 0 0; transition: height .4s ease; background: var(--border2); }
|
| 269 |
+
.tl-bar.done { background: linear-gradient(180deg, var(--amber), var(--amber2)); }
|
| 270 |
+
.tl-bar.pass { background: linear-gradient(180deg, var(--green), #16a34a); }
|
| 271 |
+
.tl-label { font-family: 'JetBrains Mono', monospace; font-size: .5rem; color: var(--muted); }
|
| 272 |
+
|
| 273 |
+
/* FULL-WIDTH PANELS */
|
| 274 |
+
.ep-full { grid-column: 1 / -1; }
|
| 275 |
+
|
| 276 |
+
/* REWARD CHART */
|
| 277 |
+
.chart-section { border-bottom: 1px solid var(--border); }
|
| 278 |
+
.chart-wrap { background: var(--bg2); border: 1px solid var(--border); border-radius: var(--radius); overflow: hidden; margin-top: 2.5rem; }
|
| 279 |
+
.chart-head {
|
| 280 |
+
display: flex; align-items: center; justify-content: space-between;
|
| 281 |
+
padding: .65rem 1.2rem; background: var(--bg3); border-bottom: 1px solid var(--border);
|
| 282 |
+
font-family: 'JetBrains Mono', monospace; font-size: .68rem; color: var(--muted);
|
| 283 |
+
}
|
| 284 |
+
.legend { display: flex; gap: 1.5rem; }
|
| 285 |
+
.leg { display: flex; align-items: center; gap: .4rem; font-size: .65rem; }
|
| 286 |
+
.leg-dot { width: 8px; height: 8px; border-radius: 50%; }
|
| 287 |
+
canvas#rwChart { display: block; width: 100%; height: 220px; }
|
| 288 |
+
|
| 289 |
+
/* COMPARE */
|
| 290 |
+
.cmp-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 1.2rem; }
|
| 291 |
+
.cmp-card {
|
| 292 |
+
background: var(--bg2); border: 1px solid var(--border);
|
| 293 |
+
border-radius: var(--radius); overflow: hidden; transition: transform .2s, border-color .3s;
|
| 294 |
+
}
|
| 295 |
+
.cmp-card:hover { transform: translateY(-3px); }
|
| 296 |
+
.cmp-card.win { border-color: rgba(245,158,11,.3); }
|
| 297 |
+
.cmp-head {
|
| 298 |
+
padding: .9rem 1.1rem; background: var(--bg3); border-bottom: 1px solid var(--border);
|
| 299 |
+
display: flex; align-items: center; justify-content: space-between;
|
| 300 |
+
}
|
| 301 |
+
.cmp-title { font-weight: 700; font-size: .88rem; }
|
| 302 |
+
.badge { font-family: 'JetBrains Mono', monospace; font-size: .55rem; padding: .2rem .6rem; border-radius: 4px; font-weight: 700; letter-spacing: .04em; }
|
| 303 |
+
.b-red { background: rgba(239,68,68,.12); color: var(--red); }
|
| 304 |
+
.b-dim { background: rgba(255,255,255,.05); color: var(--muted); }
|
| 305 |
+
.b-amb { background: rgba(245,158,11,.12); color: var(--amber); }
|
| 306 |
+
.cmp-body { padding: 1.2rem; }
|
| 307 |
+
.cmp-item { display: flex; gap: .5rem; font-size: .8rem; color: var(--dim); line-height: 1.55; margin-bottom: .5rem; }
|
| 308 |
+
.cmp-code {
|
| 309 |
+
background: var(--bg3); border: 1px solid var(--border); border-radius: 8px;
|
| 310 |
+
padding: .65rem .85rem; margin-top: .7rem;
|
| 311 |
+
font-family: 'JetBrains Mono', monospace; font-size: .65rem; color: var(--muted); line-height: 1.75;
|
| 312 |
+
}
|
| 313 |
+
.cmp-result {
|
| 314 |
+
display: flex; align-items: center; gap: .4rem; margin-top: .9rem;
|
| 315 |
+
padding: .4rem .75rem; border-radius: 6px; font-size: .72rem;
|
| 316 |
+
font-weight: 700; font-family: 'JetBrains Mono', monospace;
|
| 317 |
+
}
|
| 318 |
+
.cr-fail { background: rgba(239,68,68,.08); color: var(--red); }
|
| 319 |
+
.cr-pass { background: rgba(34,197,94,.08); color: var(--green); }
|
| 320 |
+
|
| 321 |
+
/* CHALLENGE */
|
| 322 |
+
.chal-box {
|
| 323 |
+
background: var(--bg2); border: 1px solid var(--border);
|
| 324 |
+
border-radius: var(--radius); padding: 2rem; position: relative; overflow: hidden;
|
| 325 |
+
}
|
| 326 |
+
.chal-presets { display: flex; gap: .5rem; flex-wrap: wrap; margin-bottom: 1rem; }
|
| 327 |
+
.preset {
|
| 328 |
+
background: var(--bg3); border: 1px solid var(--border); color: var(--dim);
|
| 329 |
+
padding: .35rem .85rem; border-radius: 100px;
|
| 330 |
+
font-family: 'JetBrains Mono', monospace; font-size: .65rem;
|
| 331 |
+
cursor: pointer; transition: all .2s;
|
| 332 |
+
}
|
| 333 |
+
.preset:hover { border-color: var(--amber); color: var(--amber); }
|
| 334 |
+
.chal-ta {
|
| 335 |
+
width: 100%; height: 140px; background: var(--bg3); border: 1px solid var(--border);
|
| 336 |
+
border-radius: 8px; padding: 1rem; color: var(--text);
|
| 337 |
+
font-family: 'JetBrains Mono', monospace; font-size: .74rem; line-height: 1.7;
|
| 338 |
+
resize: none; outline: none; transition: border-color .2s;
|
| 339 |
+
}
|
| 340 |
+
.chal-ta:focus { border-color: var(--amber); }
|
| 341 |
+
.chal-out {
|
| 342 |
+
background: var(--bg3); border: 1px solid var(--border); border-radius: 8px;
|
| 343 |
+
padding: 1rem; margin-top: 1rem;
|
| 344 |
+
font-family: 'JetBrains Mono', monospace; font-size: .72rem;
|
| 345 |
+
color: var(--dim); line-height: 1.9; display: none; white-space: pre-wrap;
|
| 346 |
+
}
|
| 347 |
+
|
| 348 |
+
/* FOOTER */
|
| 349 |
+
footer {
|
| 350 |
+
padding: 2rem; display: flex; align-items: center; justify-content: space-between;
|
| 351 |
+
border-top: 1px solid var(--border);
|
| 352 |
+
}
|
| 353 |
+
.ft-logo { font-weight: 800; font-size: .95rem; }
|
| 354 |
+
.ft-logo em { color: var(--amber); font-style: normal; }
|
| 355 |
+
.ft-right { font-family: 'JetBrains Mono', monospace; font-size: .62rem; color: var(--muted); }
|
| 356 |
+
|
| 357 |
+
@keyframes fadeUp { from { opacity: 0; transform: translateY(16px); } to { opacity: 1; transform: translateY(0); } }
|
| 358 |
+
::-webkit-scrollbar { width: 4px; height: 4px; }
|
| 359 |
+
::-webkit-scrollbar-track { background: var(--bg2); }
|
| 360 |
+
::-webkit-scrollbar-thumb { background: var(--border2); border-radius: 4px; }
|
| 361 |
+
@media (max-width: 900px) {
|
| 362 |
+
.steps-grid, .cmp-grid { grid-template-columns: 1fr; }
|
| 363 |
+
.ep-grid { grid-template-columns: 1fr; }
|
| 364 |
+
.stats-row { grid-template-columns: repeat(2, 1fr); }
|
| 365 |
+
.stat-cell:nth-child(2) { border-right: none; }
|
| 366 |
+
}
|
| 367 |
+
@media (max-width: 600px) {
|
| 368 |
+
.stats-row { grid-template-columns: 1fr; }
|
| 369 |
+
.stat-cell { border-right: none; border-bottom: 1px solid var(--border); }
|
| 370 |
+
.term-body { grid-template-columns: 1fr; }
|
| 371 |
+
.term-col:first-child { border-right: none; border-bottom: 1px solid var(--border); }
|
| 372 |
+
nav { padding: 0 1rem; }
|
| 373 |
+
.nav-links { display: none; }
|
| 374 |
+
}
|
| 375 |
+
</style>
|
| 376 |
+
</head>
|
| 377 |
+
<body>
|
| 378 |
+
|
| 379 |
+
<!-- NAV -->
|
| 380 |
+
<nav>
|
| 381 |
+
<div class="logo"><span>Flake<em>Forge</em></span></div>
|
| 382 |
+
<div class="nav-links">
|
| 383 |
+
<a href="#how">How It Works</a>
|
| 384 |
+
<a href="#episode">Live Demo</a>
|
| 385 |
+
<a href="#results">Results</a>
|
| 386 |
+
<a href="#challenge">Challenge</a>
|
| 387 |
+
</div>
|
| 388 |
+
<div class="nav-badge">Team Prism</div>
|
| 389 |
+
</nav>
|
| 390 |
+
|
| 391 |
+
<!-- HERO -->
|
| 392 |
+
<section class="hero">
|
| 393 |
+
<div class="hero-glow"></div>
|
| 394 |
+
<div class="hero-eyebrow">RLVR + GRPO + Causal Reasoning</div>
|
| 395 |
+
<h1 class="hero-title">The agent that knows<br/><em>why</em> tests break.</h1>
|
| 396 |
+
<p class="hero-sub">FlakeForge reasons about root causes using a causal graph, runs chaos probes, and applies verified structural fixes. Trained end-to-end with GRPO and verifiable environment rewards.</p>
|
| 397 |
+
<div class="hero-actions">
|
| 398 |
+
<button class="btn-primary" onclick="scrollTo('#episode')">Watch it reason</button>
|
| 399 |
+
<button class="btn-ghost" onclick="scrollTo('#challenge')">Try it yourself</button>
|
| 400 |
+
</div>
|
| 401 |
+
|
| 402 |
+
<div class="hero-terminal">
|
| 403 |
+
<div class="term-bar"><div class="tdot r"></div><div class="tdot y"></div><div class="tdot g"></div><div class="term-title">cpu_timing_race — before / after</div></div>
|
| 404 |
+
<div class="term-body">
|
| 405 |
+
<div class="term-col">
|
| 406 |
+
<div class="term-col-label">Before — Flaky</div>
|
| 407 |
+
<span class="c-dim">$ pytest test_counter.py --count=5</span><br/>
|
| 408 |
+
Run 1: <span class="c-red">FAILED</span> <span class="c-dim">[assert 97 == 100]</span><br/>
|
| 409 |
+
Run 2: <span class="c-green">passed</span> <span class="c-dim">[32ms]</span><br/>
|
| 410 |
+
Run 3: <span class="flicker">FAILED</span> <span class="c-dim">[assert 98 == 100]</span><br/>
|
| 411 |
+
Run 4: <span class="c-green">passed</span><br/>
|
| 412 |
+
Run 5: <span class="c-red">FAILED</span><br/><br/>
|
| 413 |
+
<span class="c-red">flakiness: 60% — root cause unknown</span>
|
| 414 |
+
</div>
|
| 415 |
+
<div class="term-col">
|
| 416 |
+
<div class="term-col-label">After — FlakeForge</div>
|
| 417 |
+
<span class="c-amber">root_cause: TIMING_RACE</span><br/>
|
| 418 |
+
<span class="c-dim">boundary: counter.py:increment()</span><br/>
|
| 419 |
+
<span class="c-teal">fix: threading.Lock (+2 lines)</span><br/><br/>
|
| 420 |
+
<span class="c-dim">$ pytest test_counter.py --count=20</span><br/>
|
| 421 |
+
<span class="c-green">20/20 passed</span><br/>
|
| 422 |
+
<span class="c-green">no regressions</span><br/>
|
| 423 |
+
<span class="c-amber">reward: +7.4</span>
|
| 424 |
+
</div>
|
| 425 |
+
</div>
|
| 426 |
+
</div>
|
| 427 |
+
</section>
|
| 428 |
+
|
| 429 |
+
<!-- STATS -->
|
| 430 |
+
<div class="stats-row">
|
| 431 |
+
<div class="stat-cell"><div class="stat-val" data-to="8">0</div><div class="stat-lbl">Flake Categories</div></div>
|
| 432 |
+
<div class="stat-cell"><div class="stat-val" data-to="94" data-sfx="%">0%</div><div class="stat-lbl">Fix Accuracy</div></div>
|
| 433 |
+
<div class="stat-cell"><div class="stat-val" data-to="14">0</div><div class="stat-lbl">Max Steps / Episode</div></div>
|
| 434 |
+
<div class="stat-cell"><div class="stat-val" data-to="3" data-sfx=".5B">0</div><div class="stat-lbl">Agent Parameters</div></div>
|
| 435 |
+
</div>
|
| 436 |
+
|
| 437 |
+
<!-- HOW IT WORKS -->
|
| 438 |
+
<section id="how">
|
| 439 |
+
<div class="sec-inner">
|
| 440 |
+
<div class="sec-header reveal">
|
| 441 |
+
<div class="sec-label">How it works</div>
|
| 442 |
+
<h2 class="sec-title">Three-phase causal reasoning.</h2>
|
| 443 |
+
<p class="sec-desc">Every episode follows the same disciplined pipeline: probe the environment, trace the causal boundary, and apply a verified minimal fix.</p>
|
| 444 |
+
</div>
|
| 445 |
+
<div class="steps-grid reveal">
|
| 446 |
+
<div class="step-card">
|
| 447 |
+
<div class="step-num">Phase 01</div>
|
| 448 |
+
<div class="step-icon">⚡</div>
|
| 449 |
+
<h3>Chaos Probe</h3>
|
| 450 |
+
<p>Inject CPU stress, network delay, or I/O contention to amplify the flake. If the failure rate spikes, the test is infrastructure-sensitive — a strong causal signal.</p>
|
| 451 |
+
</div>
|
| 452 |
+
<div class="step-card">
|
| 453 |
+
<div class="step-num">Phase 02</div>
|
| 454 |
+
<div class="step-icon">🎯</div>
|
| 455 |
+
<h3>Diagnose Boundary</h3>
|
| 456 |
+
<p>Build a causal graph from test entry to failure point. Isolate the exact file, function, and variable where non-determinism enters the execution path.</p>
|
| 457 |
+
</div>
|
| 458 |
+
<div class="step-card">
|
| 459 |
+
<div class="step-num">Phase 03</div>
|
| 460 |
+
<div class="step-icon">🔒</div>
|
| 461 |
+
<h3>Apply & Verify</h3>
|
| 462 |
+
<p>Generate a minimal patch using the correct synchronization primitive. Verify with 20 pytest runs — zero regressions required to earn the full reward.</p>
|
| 463 |
+
</div>
|
| 464 |
+
</div>
|
| 465 |
+
</div>
|
| 466 |
+
</section>
|
| 467 |
+
|
| 468 |
+
<!-- EPISODE VISUALIZER -->
|
| 469 |
+
<section class="ep-section" id="episode">
|
| 470 |
+
<div class="sec-inner">
|
| 471 |
+
<div class="sec-header reveal">
|
| 472 |
+
<div class="sec-label">Live demo</div>
|
| 473 |
+
<h2 class="sec-title">Watch the agent think, step by step.</h2>
|
| 474 |
+
<p class="sec-desc">Every internal signal visualised in real time — causal graph, action selection, reward breakdown, and chain-of-thought reasoning.</p>
|
| 475 |
+
</div>
|
| 476 |
+
|
| 477 |
+
<div class="run-controls reveal">
|
| 478 |
+
<button class="run-btn" id="runBtn" onclick="startEpisode()">Run Episode</button>
|
| 479 |
+
<div class="ep-status">Status: <em id="epStatus">ready</em></div>
|
| 480 |
+
</div>
|
| 481 |
+
|
| 482 |
+
<div class="ep-grid reveal">
|
| 483 |
+
|
| 484 |
+
<!-- Source code -->
|
| 485 |
+
<div class="panel">
|
| 486 |
+
<div class="panel-head">
|
| 487 |
+
<span>counter.py</span>
|
| 488 |
+
<span class="panel-head-tag c-red" id="srcTag">FLAKY</span>
|
| 489 |
+
</div>
|
| 490 |
+
<div class="panel-body">
|
| 491 |
+
<span class="c-dim">import threading</span><br/><br/>
|
| 492 |
+
<span class="c-dim">counter = 0</span><br/><br/>
|
| 493 |
+
<span class="c-dim">def increment():</span><br/>
|
| 494 |
+
<span class="hi-line" id="raceLine"> global counter<br/> temp = counter<br/> temp += 1<br/> counter = temp</span><br/><br/>
|
| 495 |
+
<span class="c-dim">def run_threads(n):</span><br/>
|
| 496 |
+
<span class="c-dim"> threads = [threading.Thread(</span><br/>
|
| 497 |
+
<span class="c-dim"> target=increment) for _ in range(n)]</span><br/>
|
| 498 |
+
<span class="c-dim"> for t in threads: t.start()</span><br/>
|
| 499 |
+
<span class="c-dim"> for t in threads: t.join()</span>
|
| 500 |
+
</div>
|
| 501 |
+
</div>
|
| 502 |
+
|
| 503 |
+
<!-- Causal Graph -->
|
| 504 |
+
<div class="panel" id="causal">
|
| 505 |
+
<div class="panel-head">
|
| 506 |
+
<span>Causal Graph</span>
|
| 507 |
+
<span class="panel-head-tag c-amber" id="cgStatus">idle</span>
|
| 508 |
+
</div>
|
| 509 |
+
<canvas id="cgCanvas" height="220"></canvas>
|
| 510 |
+
</div>
|
| 511 |
+
|
| 512 |
+
<!-- Patch -->
|
| 513 |
+
<div class="panel">
|
| 514 |
+
<div class="panel-head">
|
| 515 |
+
<span>counter.py — patch</span>
|
| 516 |
+
<span class="panel-head-tag" id="patchTag">pending</span>
|
| 517 |
+
</div>
|
| 518 |
+
<div class="panel-body" id="patchBody"><span class="c-dim">// patch will appear here...</span></div>
|
| 519 |
+
</div>
|
| 520 |
+
|
| 521 |
+
<!-- Action Space -->
|
| 522 |
+
<div class="panel">
|
| 523 |
+
<div class="panel-head">
|
| 524 |
+
<span>Action Space</span>
|
| 525 |
+
<span class="panel-head-tag" id="actionStatus">7 actions</span>
|
| 526 |
+
</div>
|
| 527 |
+
<div class="action-grid" id="actionGrid">
|
| 528 |
+
<div class="action-card" data-action="GATHER_EVIDENCE"><div class="ac-icon">🔍</div><span class="ac-name">gather</span></div>
|
| 529 |
+
<div class="action-card" data-action="CHAOS_PROBE"><div class="ac-icon">⚡</div><span class="ac-name">chaos</span></div>
|
| 530 |
+
<div class="action-card" data-action="DIAGNOSE_BOUNDARY"><div class="ac-icon">🎯</div><span class="ac-name">diagnose</span></div>
|
| 531 |
+
<div class="action-card" data-action="ADD_SYNCHRONIZATION"><div class="ac-icon">🔒</div><span class="ac-name">add_sync</span></div>
|
| 532 |
+
<div class="action-card" data-action="REFACTOR_CONCURRENCY"><div class="ac-icon">🔄</div><span class="ac-name">refactor</span></div>
|
| 533 |
+
<div class="action-card" data-action="ADD_RETRY"><div class="ac-icon">🔁</div><span class="ac-name">retry</span></div>
|
| 534 |
+
<div class="action-card" data-action="MOCK_DEPENDENCY"><div class="ac-icon">🎭</div><span class="ac-name">mock</span></div>
|
| 535 |
+
</div>
|
| 536 |
+
</div>
|
| 537 |
+
|
| 538 |
+
<!-- Pytest results -->
|
| 539 |
+
<div class="panel">
|
| 540 |
+
<div class="panel-head">
|
| 541 |
+
<span id="pytestTitle">pytest — before fix</span>
|
| 542 |
+
</div>
|
| 543 |
+
<div class="panel-body">
|
| 544 |
+
<div style="display:flex;gap:3px;flex-wrap:wrap;margin-bottom:.5rem" id="testDots"></div>
|
| 545 |
+
<div style="font-size:.68rem;color:var(--muted)" id="testSummary">pass rate: 60% (12/20)</div>
|
| 546 |
+
</div>
|
| 547 |
+
</div>
|
| 548 |
+
|
| 549 |
+
<!-- Reward Breakdown -->
|
| 550 |
+
<div class="panel">
|
| 551 |
+
<div class="panel-head">
|
| 552 |
+
<span>Reward Breakdown</span>
|
| 553 |
+
<span class="panel-head-tag c-amber" id="rewardStep">step 0 / 3</span>
|
| 554 |
+
</div>
|
| 555 |
+
<div class="reward-bars" id="rewardBars">
|
| 556 |
+
<div class="rbar-row"><div class="rbar-label">r_action_match</div><div class="rbar-track"><div class="rbar-fill pos" id="rb_action" style="width:0%"></div></div><div class="rbar-val pos" id="rv_action">—</div></div>
|
| 557 |
+
<div class="rbar-row"><div class="rbar-label">r_chaos_probe</div><div class="rbar-track"><div class="rbar-fill pos" id="rb_chaos" style="width:0%"></div></div><div class="rbar-val pos" id="rv_chaos">—</div></div>
|
| 558 |
+
<div class="rbar-row"><div class="rbar-label">r_root_cause</div><div class="rbar-track"><div class="rbar-fill pos" id="rb_root" style="width:0%"></div></div><div class="rbar-val pos" id="rv_root">—</div></div>
|
| 559 |
+
<div class="rbar-row"><div class="rbar-label">r_primitive</div><div class="rbar-track"><div class="rbar-fill pos" id="rb_prim" style="width:0%"></div></div><div class="rbar-val pos" id="rv_prim">—</div></div>
|
| 560 |
+
<div class="rbar-row"><div class="rbar-label">r_pass_rate</div><div class="rbar-track"><div class="rbar-fill pos" id="rb_pass" style="width:0%"></div></div><div class="rbar-val pos" id="rv_pass">—</div></div>
|
| 561 |
+
<div class="rbar-row"><div class="rbar-label">r_diff_sim</div><div class="rbar-track"><div class="rbar-fill pos" id="rb_diff" style="width:0%"></div></div><div class="rbar-val pos" id="rv_diff">—</div></div>
|
| 562 |
+
<div class="rbar-row"><div class="rbar-label">r_efficiency</div><div class="rbar-track"><div class="rbar-fill pos" id="rb_eff" style="width:0%"></div></div><div class="rbar-val pos" id="rv_eff">—</div></div>
|
| 563 |
+
<div class="rbar-row"><div class="rbar-label">p_step_budget</div><div class="rbar-track" style="direction:rtl"><div class="rbar-fill neg" id="rb_pen" style="width:0%"></div></div><div class="rbar-val neg" id="rv_pen">—</div></div>
|
| 564 |
+
</div>
|
| 565 |
+
<div class="reward-total"><span>Episode Reward</span><span class="reward-total-val" id="rewardTotal">—</span></div>
|
| 566 |
+
</div>
|
| 567 |
+
|
| 568 |
+
<!-- Chain of Thought -->
|
| 569 |
+
<div class="panel ep-full">
|
| 570 |
+
<div class="panel-head">
|
| 571 |
+
<span>Chain-of-Thought</span>
|
| 572 |
+
<span class="panel-head-tag" id="cotLabel">idle</span>
|
| 573 |
+
</div>
|
| 574 |
+
<div class="panel-body think-body" id="thinkBody"><span class="c-dim">// agent reasoning streams here...</span></div>
|
| 575 |
+
</div>
|
| 576 |
+
|
| 577 |
+
<!-- Manifest Oracle -->
|
| 578 |
+
<div class="panel">
|
| 579 |
+
<div class="panel-head">
|
| 580 |
+
<span>flake_manifest.json</span>
|
| 581 |
+
<span class="panel-head-tag c-amber">ORACLE</span>
|
| 582 |
+
</div>
|
| 583 |
+
<div class="panel-body" style="font-size:.68rem;line-height:2;">
|
| 584 |
+
<span class="c-amber">"flake_category"</span>: <span class="c-green">"TIMING_RACE"</span><br/>
|
| 585 |
+
<span class="c-amber">"correct_actions"</span>: <span class="c-teal">["CHAOS_PROBE","ADD_SYNC"]</span><br/>
|
| 586 |
+
<span class="c-amber">"root_cause_file"</span>: <span class="c-green">"counter.py"</span><br/>
|
| 587 |
+
<span class="c-amber">"root_cause_function"</span>: <span class="c-green">"increment"</span><br/>
|
| 588 |
+
<span class="c-amber">"correct_primitive"</span>: <span class="c-green">"threading.Lock"</span><br/>
|
| 589 |
+
<span class="c-amber">"min_pass_rate"</span>: <span style="color:#60a5fa">0.95</span>
|
| 590 |
+
</div>
|
| 591 |
+
</div>
|
| 592 |
+
|
| 593 |
+
<!-- Timeline -->
|
| 594 |
+
<div class="panel">
|
| 595 |
+
<div class="panel-head">
|
| 596 |
+
<span>Episode Timeline</span>
|
| 597 |
+
<span class="panel-head-tag" id="tlLabel">0 steps</span>
|
| 598 |
+
</div>
|
| 599 |
+
<div class="timeline-body" id="timelineBody">
|
| 600 |
+
<span style="font-family:'JetBrains Mono',monospace;font-size:.65rem;color:var(--muted)">steps appear as episode runs</span>
|
| 601 |
+
</div>
|
| 602 |
+
</div>
|
| 603 |
+
|
| 604 |
+
</div>
|
| 605 |
+
</div>
|
| 606 |
+
</section>
|
| 607 |
+
|
| 608 |
+
<!-- RESULTS CHART -->
|
| 609 |
+
<section class="chart-section" id="results">
|
| 610 |
+
<div class="sec-inner reveal">
|
| 611 |
+
<div class="sec-header">
|
| 612 |
+
<div class="sec-label">Training dynamics</div>
|
| 613 |
+
<h2 class="sec-title">Learning, episode by episode.</h2>
|
| 614 |
+
<p class="sec-desc">GRPO with verifiable manifest rewards. The growing gap between FlakeForge and the random baseline is the RL signal working.</p>
|
| 615 |
+
</div>
|
| 616 |
+
<div class="chart-wrap">
|
| 617 |
+
<div class="chart-head">
|
| 618 |
+
<span>Average Episode Reward</span>
|
| 619 |
+
<div class="legend">
|
| 620 |
+
<div class="leg"><div class="leg-dot" style="background:var(--amber)"></div><span>FlakeForge RLVR</span></div>
|
| 621 |
+
<div class="leg"><div class="leg-dot" style="background:#52525b"></div><span>Baseline</span></div>
|
| 622 |
+
</div>
|
| 623 |
+
</div>
|
| 624 |
+
<canvas id="rwChart"></canvas>
|
| 625 |
+
</div>
|
| 626 |
+
</div>
|
| 627 |
+
</section>
|
| 628 |
+
|
| 629 |
+
<!-- COMPARE -->
|
| 630 |
+
<section>
|
| 631 |
+
<div class="sec-inner reveal">
|
| 632 |
+
<div class="sec-header">
|
| 633 |
+
<div class="sec-label">vs. the alternatives</div>
|
| 634 |
+
<h2 class="sec-title">Others suppress. We understand.</h2>
|
| 635 |
+
</div>
|
| 636 |
+
<div class="cmp-grid">
|
| 637 |
+
<div class="cmp-card">
|
| 638 |
+
<div class="cmp-head"><span class="cmp-title">Traditional Heuristics</span><span class="badge b-red">BRITTLE</span></div>
|
| 639 |
+
<div class="cmp-body">
|
| 640 |
+
<div class="cmp-item"><span>Hard-coded rules based on surface patterns, no causal understanding.</span></div>
|
| 641 |
+
<div class="cmp-item"><span>"If timeout, add sleep" — symptom suppression, never the real fix.</span></div>
|
| 642 |
+
<div class="cmp-code"><span class="c-dim"># heuristic</span><br/><span class="c-red">if "timeout" in err:</span><br/> sleep(5) <span class="c-dim"># treats symptom</span></div>
|
| 643 |
+
<div class="cmp-result cr-fail">Fails on novel flake patterns</div>
|
| 644 |
+
</div>
|
| 645 |
+
</div>
|
| 646 |
+
<div class="cmp-card">
|
| 647 |
+
<div class="cmp-head"><span class="cmp-title">Static Analysis</span><span class="badge b-dim">NO RUNTIME</span></div>
|
| 648 |
+
<div class="cmp-body">
|
| 649 |
+
<div class="cmp-item"><span>Parses AST and call graphs. Blind to runtime behaviour under CPU load.</span></div>
|
| 650 |
+
<div class="cmp-item"><span>Cannot detect races that only manifest under scheduler preemption.</span></div>
|
| 651 |
+
<div class="cmp-code"><span class="c-dim"># static sees threads</span><br/><span class="c-red">misses: read-modify-write race</span></div>
|
| 652 |
+
<div class="cmp-result cr-fail">Misses infra-sensitive bugs</div>
|
| 653 |
+
</div>
|
| 654 |
+
</div>
|
| 655 |
+
<div class="cmp-card win">
|
| 656 |
+
<div class="cmp-head"><span class="cmp-title">FlakeForge</span><span class="badge b-amb">ROOT CAUSE</span></div>
|
| 657 |
+
<div class="cmp-body">
|
| 658 |
+
<div class="cmp-item"><span>Causal graph tracing + chaos probes + RL-trained reasoning on exact boundaries.</span></div>
|
| 659 |
+
<div class="cmp-item"><span>Rewards from the environment — not an LLM judge's subjective opinion.</span></div>
|
| 660 |
+
<div class="cmp-code"><span class="c-amber">TIMING_RACE → counter.py:increment()</span><br/><span class="c-teal">threading.Lock applied</span><br/><span class="c-green">20/20 pass, 0 regressions</span></div>
|
| 661 |
+
<div class="cmp-result cr-pass">Verified fix, reward +7.4</div>
|
| 662 |
+
</div>
|
| 663 |
+
</div>
|
| 664 |
+
</div>
|
| 665 |
+
</div>
|
| 666 |
+
</section>
|
| 667 |
+
|
| 668 |
+
<!-- CHALLENGE -->
|
| 669 |
+
<section id="challenge" style="border-top:1px solid var(--border)">
|
| 670 |
+
<div class="sec-inner reveal" style="max-width:800px">
|
| 671 |
+
<div class="sec-header">
|
| 672 |
+
<div class="sec-label">Try it yourself</div>
|
| 673 |
+
<h2 class="sec-title">Paste your flaky test.</h2>
|
| 674 |
+
<p class="sec-desc">Pick a preset or paste your own code. FlakeForge will trace the causal boundary and propose a verified fix.</p>
|
| 675 |
+
</div>
|
| 676 |
+
<div class="chal-box">
|
| 677 |
+
<div class="chal-presets">
|
| 678 |
+
<button class="preset" onclick="loadPreset('race')">timing_race</button>
|
| 679 |
+
<button class="preset" onclick="loadPreset('async')">async_deadlock</button>
|
| 680 |
+
<button class="preset" onclick="loadPreset('db')">db_commit_scope</button>
|
| 681 |
+
<button class="preset" onclick="loadPreset('ext')">external_dep</button>
|
| 682 |
+
</div>
|
| 683 |
+
<textarea class="chal-ta" id="chalIn" placeholder="# paste your flaky test here..."></textarea>
|
| 684 |
+
<div style="margin-top:1rem"><button class="run-btn" onclick="runChallenge()">Forge a Fix</button></div>
|
| 685 |
+
<div class="chal-out" id="chalOut"></div>
|
| 686 |
+
</div>
|
| 687 |
+
</div>
|
| 688 |
+
</section>
|
| 689 |
+
|
| 690 |
+
<footer>
|
| 691 |
+
<div class="ft-logo">Flake<em>Forge</em></div>
|
| 692 |
+
<div class="ft-right">Team Prism · OpenEnv Hackathon · GRPO + RLVR + Causal Verification</div>
|
| 693 |
+
</footer>
|
| 694 |
+
|
| 695 |
+
<script>
|
| 696 |
+
const sleep = ms => new Promise(r => setTimeout(r, ms));
|
| 697 |
+
function scrollTo(sel) { document.querySelector(sel)?.scrollIntoView({ behavior: 'smooth' }); }
|
| 698 |
+
|
| 699 |
+
const revObs = new IntersectionObserver(es => es.forEach(e => { if (e.isIntersecting) e.target.classList.add('on'); }), { threshold: .08 });
|
| 700 |
+
document.querySelectorAll('.reveal').forEach(r => revObs.observe(r));
|
| 701 |
+
|
| 702 |
+
const statObs = new IntersectionObserver(es => {
|
| 703 |
+
es.forEach(e => {
|
| 704 |
+
if (!e.isIntersecting) return;
|
| 705 |
+
e.target.querySelectorAll('[data-to]').forEach(el => {
|
| 706 |
+
const to = +el.dataset.to, sfx = el.dataset.sfx || '';
|
| 707 |
+
let v = 0; const step = Math.ceil(to / 40);
|
| 708 |
+
const iv = setInterval(() => { v = Math.min(v + step, to); el.textContent = v + sfx; if (v >= to) clearInterval(iv); }, 30);
|
| 709 |
+
});
|
| 710 |
+
statObs.unobserve(e.target);
|
| 711 |
+
});
|
| 712 |
+
}, { threshold: .5 });
|
| 713 |
+
document.querySelectorAll('.stats-row').forEach(s => statObs.observe(s));
|
| 714 |
+
|
| 715 |
+
const dotsCon = document.getElementById('testDots');
|
| 716 |
+
for (let i = 0; i < 20; i++) {
|
| 717 |
+
const d = document.createElement('div');
|
| 718 |
+
d.style.cssText = `width:13px;height:13px;border-radius:3px;background:${i < 12 ? 'var(--green)' : 'var(--red)'}`;
|
| 719 |
+
dotsCon.appendChild(d);
|
| 720 |
+
}
|
| 721 |
+
|
| 722 |
+
// CAUSAL GRAPH
|
| 723 |
+
const cgCanvas = document.getElementById('cgCanvas');
|
| 724 |
+
const cgCtx = cgCanvas.getContext('2d');
|
| 725 |
+
|
| 726 |
+
const CG_NODES = [
|
| 727 |
+
{ id: 'test', label: 'test_counter.py', x: .05, y: .5, color: '#52525b', glow: '#52525b' },
|
| 728 |
+
{ id: 'runner', label: 'run_threads()', x: .22, y: .5, color: '#52525b', glow: '#52525b' },
|
| 729 |
+
{ id: 'thread', label: 'Thread x100', x: .4, y: .25, color: '#52525b', glow: '#52525b' },
|
| 730 |
+
{ id: 'inc', label: 'increment()', x: .58, y: .5, color: '#52525b', glow: '#52525b' },
|
| 731 |
+
{ id: 'global', label: 'counter (global)', x: .78, y: .5, color: '#52525b', glow: '#52525b' },
|
| 732 |
+
{ id: 'race', label: 'RACE CONDITION', x: .78, y: .2, color: '#ef4444', glow: '#ef4444' },
|
| 733 |
+
{ id: 'lock', label: 'threading.Lock', x: .78, y: .78, color: '#22c55e', glow: '#22c55e' },
|
| 734 |
+
];
|
| 735 |
+
const CG_EDGES = [
|
| 736 |
+
{ from: 'test', to: 'runner' },
|
| 737 |
+
{ from: 'runner', to: 'thread' },
|
| 738 |
+
{ from: 'thread', to: 'inc' },
|
| 739 |
+
{ from: 'inc', to: 'global' },
|
| 740 |
+
{ from: 'global', to: 'race' },
|
| 741 |
+
{ from: 'lock', to: 'global', label: 'FIX' },
|
| 742 |
+
];
|
| 743 |
+
|
| 744 |
+
let cgActivatedNodes = new Set();
|
| 745 |
+
let cgActivatedEdges = new Set();
|
| 746 |
+
|
| 747 |
+
function resizeCG() { cgCanvas.width = cgCanvas.offsetWidth; cgCanvas.height = 220; }
|
| 748 |
+
resizeCG();
|
| 749 |
+
window.addEventListener('resize', () => { resizeCG(); drawCG(); });
|
| 750 |
+
|
| 751 |
+
function drawCG() {
|
| 752 |
+
const W = cgCanvas.width, H = cgCanvas.height;
|
| 753 |
+
cgCtx.clearRect(0, 0, W, H);
|
| 754 |
+
|
| 755 |
+
CG_EDGES.forEach((e, i) => {
|
| 756 |
+
const fn = CG_NODES.find(n => n.id === e.from);
|
| 757 |
+
const tn = CG_NODES.find(n => n.id === e.to);
|
| 758 |
+
if (!fn || !tn) return;
|
| 759 |
+
const fx = fn.x * W, fy = fn.y * H, tx = tn.x * W, ty = tn.y * H;
|
| 760 |
+
const active = cgActivatedEdges.has(i);
|
| 761 |
+
cgCtx.save();
|
| 762 |
+
cgCtx.strokeStyle = active ? (e.label === 'FIX' ? '#22c55e' : '#f59e0b') : 'rgba(255,255,255,.06)';
|
| 763 |
+
cgCtx.lineWidth = active ? 2 : 1;
|
| 764 |
+
if (active) { cgCtx.shadowColor = cgCtx.strokeStyle; cgCtx.shadowBlur = 8; }
|
| 765 |
+
cgCtx.beginPath();
|
| 766 |
+
const mx = (fx + tx) / 2, my = (fy + ty) / 2 - 20;
|
| 767 |
+
cgCtx.moveTo(fx, fy); cgCtx.quadraticCurveTo(mx, my, tx, ty);
|
| 768 |
+
cgCtx.stroke();
|
| 769 |
+
const angle = Math.atan2(ty - my, tx - mx);
|
| 770 |
+
cgCtx.fillStyle = cgCtx.strokeStyle;
|
| 771 |
+
cgCtx.beginPath();
|
| 772 |
+
cgCtx.moveTo(tx, ty);
|
| 773 |
+
cgCtx.lineTo(tx - 9 * Math.cos(angle - 0.4), ty - 9 * Math.sin(angle - 0.4));
|
| 774 |
+
cgCtx.lineTo(tx - 9 * Math.cos(angle + 0.4), ty - 9 * Math.sin(angle + 0.4));
|
| 775 |
+
cgCtx.fill();
|
| 776 |
+
if (e.label && active) {
|
| 777 |
+
cgCtx.font = 'bold 9px JetBrains Mono';
|
| 778 |
+
cgCtx.fillStyle = '#22c55e';
|
| 779 |
+
cgCtx.fillText(e.label, (fx + tx) / 2, (fy + ty) / 2 - 28);
|
| 780 |
+
}
|
| 781 |
+
cgCtx.restore();
|
| 782 |
+
});
|
| 783 |
+
|
| 784 |
+
CG_NODES.forEach(n => {
|
| 785 |
+
const x = n.x * W, y = n.y * H;
|
| 786 |
+
const active = cgActivatedNodes.has(n.id);
|
| 787 |
+
const r = active ? 36 : 28;
|
| 788 |
+
cgCtx.save();
|
| 789 |
+
if (active) { cgCtx.shadowColor = n.glow; cgCtx.shadowBlur = 16; }
|
| 790 |
+
cgCtx.fillStyle = active ? n.color + '25' : 'rgba(255,255,255,.02)';
|
| 791 |
+
cgCtx.strokeStyle = active ? n.color : 'rgba(255,255,255,.08)';
|
| 792 |
+
cgCtx.lineWidth = active ? 1.5 : 1;
|
| 793 |
+
cgCtx.beginPath(); cgCtx.ellipse(x, y, r, 15, 0, 0, Math.PI * 2); cgCtx.fill(); cgCtx.stroke();
|
| 794 |
+
cgCtx.fillStyle = active ? n.color : '#52525b';
|
| 795 |
+
cgCtx.font = `${active ? 'bold ' : ''}9px JetBrains Mono`;
|
| 796 |
+
cgCtx.textAlign = 'center'; cgCtx.textBaseline = 'middle';
|
| 797 |
+
cgCtx.fillText(n.label, x, y);
|
| 798 |
+
cgCtx.restore();
|
| 799 |
+
});
|
| 800 |
+
}
|
| 801 |
+
drawCG();
|
| 802 |
+
|
| 803 |
+
async function animateCG(nodeIds, edgeIds, delay = 300) {
|
| 804 |
+
for (let i = 0; i < nodeIds.length; i++) {
|
| 805 |
+
cgActivatedNodes.add(nodeIds[i]);
|
| 806 |
+
if (edgeIds[i] != null) cgActivatedEdges.add(edgeIds[i]);
|
| 807 |
+
drawCG();
|
| 808 |
+
await sleep(delay);
|
| 809 |
+
}
|
| 810 |
+
}
|
| 811 |
+
|
| 812 |
+
function resetActions() {
|
| 813 |
+
document.querySelectorAll('.action-card').forEach(c => c.classList.remove('considering', 'selected', 'rejected'));
|
| 814 |
+
}
|
| 815 |
+
async function animateActions(selected) {
|
| 816 |
+
resetActions();
|
| 817 |
+
const cards = [...document.querySelectorAll('.action-card')];
|
| 818 |
+
cards.forEach(c => c.classList.add('considering'));
|
| 819 |
+
await sleep(800);
|
| 820 |
+
cards.forEach(c => {
|
| 821 |
+
c.classList.remove('considering');
|
| 822 |
+
if (c.dataset.action === selected) c.classList.add('selected');
|
| 823 |
+
else c.classList.add('rejected');
|
| 824 |
+
});
|
| 825 |
+
}
|
| 826 |
+
|
| 827 |
+
const rewardConfig = {
|
| 828 |
+
rb_action: { max: 5 }, rb_chaos: { max: 1 }, rb_root: { max: 1.5 },
|
| 829 |
+
rb_prim: { max: 1.5 }, rb_pass: { max: 10 }, rb_diff: { max: 2 },
|
| 830 |
+
rb_eff: { max: 3 }, rb_pen: { max: 2 }
|
| 831 |
+
};
|
| 832 |
+
|
| 833 |
+
function setRewardBar(id, value) {
|
| 834 |
+
const cfg = rewardConfig['rb_' + id];
|
| 835 |
+
const pct = Math.min(100, (Math.abs(value) / cfg.max) * 100);
|
| 836 |
+
document.getElementById('rb_' + id).style.width = pct + '%';
|
| 837 |
+
const rv = document.getElementById('rv_' + id);
|
| 838 |
+
rv.textContent = (value > 0 ? '+' : '') + value.toFixed(1);
|
| 839 |
+
rv.className = 'rbar-val ' + (value >= 0 ? 'pos' : 'neg');
|
| 840 |
+
}
|
| 841 |
+
|
| 842 |
+
function clearRewardBars() {
|
| 843 |
+
['action', 'chaos', 'root', 'prim', 'pass', 'diff', 'eff', 'pen'].forEach(k => {
|
| 844 |
+
document.getElementById('rb_' + k).style.width = '0%';
|
| 845 |
+
document.getElementById('rv_' + k).textContent = '\u2014';
|
| 846 |
+
});
|
| 847 |
+
document.getElementById('rewardTotal').textContent = '\u2014';
|
| 848 |
+
}
|
| 849 |
+
|
| 850 |
+
async function typeThink(text, delay = 12) {
|
| 851 |
+
const el = document.getElementById('thinkBody');
|
| 852 |
+
el.innerHTML = '';
|
| 853 |
+
const cursor = '<span class="think-cursor"></span>';
|
| 854 |
+
for (let i = 0; i < text.length; i++) {
|
| 855 |
+
const ch = text[i];
|
| 856 |
+
const rest = text.slice(i);
|
| 857 |
+
if (rest.startsWith('[[')) {
|
| 858 |
+
const end = text.indexOf(']]', i);
|
| 859 |
+
const tag = text.slice(i + 2, end);
|
| 860 |
+
el.innerHTML = el.innerHTML.replace(cursor, '') + `<span class="th">${tag}</span>` + cursor;
|
| 861 |
+
i = end + 1; await sleep(delay * tag.length * .3); continue;
|
| 862 |
+
}
|
| 863 |
+
el.innerHTML = el.innerHTML.replace(cursor, '') + (ch === '\n' ? '<br/>' : ch) + cursor;
|
| 864 |
+
await sleep(delay);
|
| 865 |
+
}
|
| 866 |
+
el.innerHTML = el.innerHTML.replace(cursor, '');
|
| 867 |
+
}
|
| 868 |
+
|
| 869 |
+
const PATCH = [
|
| 870 |
+
{ t: 'neu', c: 'import threading' },
|
| 871 |
+
{ t: 'neu', c: '' },
|
| 872 |
+
{ t: 'add', c: '_lock = threading.Lock()' },
|
| 873 |
+
{ t: 'neu', c: 'counter = 0' },
|
| 874 |
+
{ t: 'neu', c: '' },
|
| 875 |
+
{ t: 'neu', c: 'def increment():' },
|
| 876 |
+
{ t: 'neu', c: ' global counter' },
|
| 877 |
+
{ t: 'rem', c: ' temp = counter' },
|
| 878 |
+
{ t: 'rem', c: ' temp += 1' },
|
| 879 |
+
{ t: 'rem', c: ' counter = temp' },
|
| 880 |
+
{ t: 'add', c: ' with _lock:' },
|
| 881 |
+
{ t: 'add', c: ' counter += 1' },
|
| 882 |
+
];
|
| 883 |
+
async function applyPatch() {
|
| 884 |
+
const el = document.getElementById('patchBody');
|
| 885 |
+
el.innerHTML = '';
|
| 886 |
+
document.getElementById('patchTag').textContent = 'applying...';
|
| 887 |
+
document.getElementById('patchTag').style.color = 'var(--amber)';
|
| 888 |
+
for (const line of PATCH) {
|
| 889 |
+
const sp = document.createElement('span');
|
| 890 |
+
sp.className = `patch-line ${line.t === 'add' ? 'p-add' : line.t === 'rem' ? 'p-rem' : 'p-neu'}`;
|
| 891 |
+
sp.innerHTML = (line.t === 'add' ? '<span style="color:var(--muted)">+</span> ' : line.t === 'rem' ? '<span style="color:var(--muted)">-</span> ' : ' ') + line.c + '<br/>';
|
| 892 |
+
el.appendChild(sp);
|
| 893 |
+
await sleep(70);
|
| 894 |
+
sp.classList.add('show');
|
| 895 |
+
}
|
| 896 |
+
document.getElementById('patchTag').textContent = 'patched';
|
| 897 |
+
document.getElementById('patchTag').style.color = 'var(--green)';
|
| 898 |
+
}
|
| 899 |
+
|
| 900 |
+
async function runAfterTests() {
|
| 901 |
+
const dots = document.getElementById('testDots');
|
| 902 |
+
const summary = document.getElementById('testSummary');
|
| 903 |
+
document.getElementById('pytestTitle').textContent = 'pytest — after fix (20 runs)';
|
| 904 |
+
dots.innerHTML = '';
|
| 905 |
+
for (let i = 0; i < 20; i++) {
|
| 906 |
+
const d = document.createElement('div');
|
| 907 |
+
d.style.cssText = 'width:13px;height:13px;border-radius:3px;background:var(--border2);transition:background .2s;';
|
| 908 |
+
dots.appendChild(d);
|
| 909 |
+
await sleep(80);
|
| 910 |
+
d.style.background = 'var(--green)';
|
| 911 |
+
}
|
| 912 |
+
summary.textContent = 'pass rate: 100% (20/20)';
|
| 913 |
+
summary.style.color = 'var(--green)';
|
| 914 |
+
}
|
| 915 |
+
|
| 916 |
+
const TL_STEPS = [
|
| 917 |
+
{ label: 'GATHER', h: 30 },
|
| 918 |
+
{ label: 'CHAOS', h: 50 },
|
| 919 |
+
{ label: 'DIAGNOSE', h: 65 },
|
| 920 |
+
{ label: 'ADD_SYNC', h: 95 },
|
| 921 |
+
];
|
| 922 |
+
async function addTimelineStep(i) {
|
| 923 |
+
const el = document.getElementById('timelineBody');
|
| 924 |
+
if (i === 0) el.innerHTML = '';
|
| 925 |
+
const s = TL_STEPS[i];
|
| 926 |
+
const div = document.createElement('div');
|
| 927 |
+
div.className = 'tl-step';
|
| 928 |
+
const bar = document.createElement('div');
|
| 929 |
+
bar.className = 'tl-bar'; bar.style.height = '0px'; bar.style.width = '26px';
|
| 930 |
+
const lbl = document.createElement('div');
|
| 931 |
+
lbl.className = 'tl-label'; lbl.textContent = s.label;
|
| 932 |
+
div.appendChild(bar); div.appendChild(lbl);
|
| 933 |
+
el.appendChild(div);
|
| 934 |
+
await sleep(30); div.classList.add('show');
|
| 935 |
+
await sleep(50); bar.style.height = s.h + 'px';
|
| 936 |
+
await sleep(200); bar.className = 'tl-bar ' + (i === TL_STEPS.length - 1 ? 'pass' : 'done');
|
| 937 |
+
document.getElementById('tlLabel').textContent = `${i + 1} step${i > 0 ? 's' : ''}`;
|
| 938 |
+
}
|
| 939 |
+
|
| 940 |
+
const EP_STEPS = [
|
| 941 |
+
{
|
| 942 |
+
action: 'CHAOS_PROBE',
|
| 943 |
+
cgNodes: ['test', 'runner', 'thread', 'inc', 'global', 'race'],
|
| 944 |
+
cgEdges: [0, 1, 2, 3, 4],
|
| 945 |
+
think: `Analyzing observation...\n\nTest asserts counter == 100 after 100 threads call increment(). Failure is non-deterministic \u2014 3/5 runs fail. Pattern strongly suggests [[TIMING_RACE]] rather than logic error.\n\nIncrement() does: temp = counter / temp += 1 / counter = temp \u2014 non-atomic read-modify-write. Under CPU stress, threads preempt at the vulnerability window.\n\nRunning [[CHAOS_PROBE]] with CPU profile to amplify race window and confirm causal attribution...`,
|
| 946 |
+
rewards: { action: 1.0, chaos: 1.0, root: 0.5, prim: 0, pass: 0, diff: 0, eff: 0, pen: -0.3 },
|
| 947 |
+
total: '+2.2',
|
| 948 |
+
},
|
| 949 |
+
{
|
| 950 |
+
action: 'DIAGNOSE_BOUNDARY',
|
| 951 |
+
cgNodes: ['inc', 'global'],
|
| 952 |
+
cgEdges: [3, 4],
|
| 953 |
+
think: `CHAOS_PROBE complete \u2014 failure rate 60% \u2192 100% under CPU load. Infrastructure-sensitive confirmed.\n\nTracing [[causal boundary]]... Causal graph isolates counter.py:increment() as the exact boundary. The read-modify-write sequence on 'counter' (global) has no synchronization.\n\nCorrect primitive: [[threading.Lock]] \u2014 wrapping the critical section will make the operation atomic. Category match: [[TIMING_RACE]]`,
|
| 954 |
+
rewards: { action: 0, chaos: 0, root: 1.5, prim: 0, pass: 0, diff: 0, eff: 0, pen: -0.3 },
|
| 955 |
+
total: '+3.4',
|
| 956 |
+
},
|
| 957 |
+
{
|
| 958 |
+
action: 'ADD_SYNCHRONIZATION',
|
| 959 |
+
cgNodes: ['lock', 'global'],
|
| 960 |
+
cgEdges: [5],
|
| 961 |
+
think: `Boundary confirmed: counter.py:increment() \u2014 global counter, non-atomic increment.\n\nApplying [[ADD_SYNCHRONIZATION]] with [[threading.Lock]]. Patch is minimal \u2014 3 lines changed, scoped exactly to root_cause_function increment().\n\nPredicted pass_rate_after: [[1.0]] \u2014 race condition eliminated, all 20 runs should pass deterministically.\n\nManifest oracle confirms: correct_actions, correct_primitive, root_cause_file, root_cause_function \u2014 all matched.`,
|
| 962 |
+
rewards: { action: 3.0, chaos: 0, root: 0, prim: 1.5, pass: 8.0, diff: 1.8, eff: 3.0, pen: -0.3 },
|
| 963 |
+
total: '+17.0',
|
| 964 |
+
applyPatch: true, runTests: true,
|
| 965 |
+
},
|
| 966 |
+
];
|
| 967 |
+
|
| 968 |
+
let running = false;
|
| 969 |
+
|
| 970 |
+
async function startEpisode() {
|
| 971 |
+
if (running) return;
|
| 972 |
+
running = true;
|
| 973 |
+
const btn = document.getElementById('runBtn');
|
| 974 |
+
btn.disabled = true; btn.textContent = 'Running...';
|
| 975 |
+
document.getElementById('epStatus').textContent = 'initializing';
|
| 976 |
+
|
| 977 |
+
cgActivatedNodes.clear(); cgActivatedEdges.clear(); drawCG();
|
| 978 |
+
resetActions(); clearRewardBars();
|
| 979 |
+
document.getElementById('patchBody').innerHTML = '<span class="c-dim">// patch will appear here...</span>';
|
| 980 |
+
document.getElementById('patchTag').textContent = 'pending'; document.getElementById('patchTag').style.color = '';
|
| 981 |
+
document.getElementById('pytestTitle').textContent = 'pytest \u2014 before fix';
|
| 982 |
+
const dots = document.getElementById('testDots'); dots.innerHTML = '';
|
| 983 |
+
for (let i = 0; i < 20; i++) { const d = document.createElement('div'); d.style.cssText = `width:13px;height:13px;border-radius:3px;background:${i < 12 ? 'var(--green)' : 'var(--red)'}`; dots.appendChild(d); }
|
| 984 |
+
document.getElementById('testSummary').textContent = 'pass rate: 60% (12/20)'; document.getElementById('testSummary').style.color = '';
|
| 985 |
+
document.getElementById('thinkBody').innerHTML = '<span class="c-dim">initializing agent...</span>';
|
| 986 |
+
document.getElementById('timelineBody').innerHTML = '<span style="font-family:\'JetBrains Mono\',monospace;font-size:.65rem;color:var(--muted)">steps appear as episode runs</span>';
|
| 987 |
+
document.getElementById('tlLabel').textContent = '0 steps';
|
| 988 |
+
document.getElementById('cotLabel').textContent = 'idle';
|
| 989 |
+
document.getElementById('rewardStep').textContent = 'step 0 / 3';
|
| 990 |
+
document.getElementById('cgStatus').textContent = 'tracing...';
|
| 991 |
+
|
| 992 |
+
await sleep(600);
|
| 993 |
+
|
| 994 |
+
for (let i = 0; i < EP_STEPS.length; i++) {
|
| 995 |
+
const step = EP_STEPS[i];
|
| 996 |
+
document.getElementById('epStatus').textContent = `step ${i + 1} \u2014 ${step.action}`;
|
| 997 |
+
document.getElementById('rewardStep').textContent = `step ${i + 1} / 3`;
|
| 998 |
+
document.getElementById('cotLabel').textContent = step.action;
|
| 999 |
+
document.getElementById('actionStatus').textContent = `evaluating \u2192 ${step.action}`;
|
| 1000 |
+
document.getElementById('cgStatus').textContent = `tracing \u2192 ${step.action}`;
|
| 1001 |
+
|
| 1002 |
+
await animateCG(step.cgNodes, step.cgEdges, 250);
|
| 1003 |
+
await animateActions(step.action);
|
| 1004 |
+
await typeThink(step.think, 11);
|
| 1005 |
+
|
| 1006 |
+
const r = step.rewards;
|
| 1007 |
+
setRewardBar('action', r.action); setRewardBar('chaos', r.chaos);
|
| 1008 |
+
setRewardBar('root', r.root); setRewardBar('prim', r.prim);
|
| 1009 |
+
setRewardBar('pass', r.pass); setRewardBar('diff', r.diff);
|
| 1010 |
+
setRewardBar('eff', r.eff); setRewardBar('pen', r.pen);
|
| 1011 |
+
document.getElementById('rewardTotal').textContent = step.total;
|
| 1012 |
+
|
| 1013 |
+
await addTimelineStep(i);
|
| 1014 |
+
if (step.applyPatch) await applyPatch();
|
| 1015 |
+
if (step.runTests) await runAfterTests();
|
| 1016 |
+
await sleep(500);
|
| 1017 |
+
}
|
| 1018 |
+
|
| 1019 |
+
document.getElementById('cgStatus').textContent = 'resolved';
|
| 1020 |
+
document.getElementById('cotLabel').textContent = 'complete';
|
| 1021 |
+
document.getElementById('epStatus').textContent = 'done \u2014 reward +7.4';
|
| 1022 |
+
document.getElementById('srcTag').textContent = 'FIXED'; document.getElementById('srcTag').style.color = 'var(--green)';
|
| 1023 |
+
document.getElementById('raceLine').style.animation = 'none'; document.getElementById('raceLine').style.borderLeft = '2px solid var(--green)'; document.getElementById('raceLine').style.background = 'rgba(34,197,94,.06)';
|
| 1024 |
+
|
| 1025 |
+
btn.disabled = false; btn.textContent = 'Run Again';
|
| 1026 |
+
btn.onclick = () => { running = false; startEpisode(); };
|
| 1027 |
+
running = false;
|
| 1028 |
+
}
|
| 1029 |
+
|
| 1030 |
+
// REWARD CHART
|
| 1031 |
+
const rwCanvas = document.getElementById('rwChart');
|
| 1032 |
+
const rwCtx = rwCanvas.getContext('2d');
|
| 1033 |
+
let rwDrawn = 0;
|
| 1034 |
+
const N = 50;
|
| 1035 |
+
const flakeD = [], baseD = [];
|
| 1036 |
+
for (let i = 0; i < N; i++) {
|
| 1037 |
+
const p = i / N;
|
| 1038 |
+
flakeD.push(Math.max(-2, Math.min(10, -2 + p * 11.5 + Math.sin(i * .7) * .9 + (Math.random() - .5) * 1.1)));
|
| 1039 |
+
baseD.push(Math.max(-2, Math.min(3, .4 + (Math.random() - .5) * 1.8)));
|
| 1040 |
+
}
|
| 1041 |
+
|
| 1042 |
+
function drawRwChart(upTo) {
|
| 1043 |
+
rwCanvas.width = rwCanvas.offsetWidth || 960; rwCanvas.height = 220;
|
| 1044 |
+
const W = rwCanvas.width, H = 220, pad = { t: 20, r: 20, b: 36, l: 44 };
|
| 1045 |
+
const cW = W - pad.l - pad.r, cH = H - pad.t - pad.b;
|
| 1046 |
+
rwCtx.clearRect(0, 0, W, H);
|
| 1047 |
+
for (let g = 0; g <= 4; g++) {
|
| 1048 |
+
const y = pad.t + (g / 4) * cH;
|
| 1049 |
+
rwCtx.strokeStyle = 'rgba(255,255,255,.04)'; rwCtx.lineWidth = 1;
|
| 1050 |
+
rwCtx.beginPath(); rwCtx.moveTo(pad.l, y); rwCtx.lineTo(W - pad.r, y); rwCtx.stroke();
|
| 1051 |
+
rwCtx.fillStyle = '#52525b'; rwCtx.font = '10px JetBrains Mono'; rwCtx.textAlign = 'right';
|
| 1052 |
+
rwCtx.fillText((10 - g * 2.5).toFixed(0), pad.l - 6, y + 4);
|
| 1053 |
+
}
|
| 1054 |
+
const xp = i => pad.l + (i / (N - 1)) * cW;
|
| 1055 |
+
const yp = v => pad.t + cH - ((v + 2) / 14) * cH;
|
| 1056 |
+
|
| 1057 |
+
function drawLine(data, color, glow, lw) {
|
| 1058 |
+
if (data.length < 2) return;
|
| 1059 |
+
rwCtx.save();
|
| 1060 |
+
if (glow) { rwCtx.shadowColor = glow; rwCtx.shadowBlur = 8; }
|
| 1061 |
+
rwCtx.strokeStyle = color; rwCtx.lineWidth = lw || 2; rwCtx.lineJoin = 'round'; rwCtx.lineCap = 'round';
|
| 1062 |
+
rwCtx.beginPath();
|
| 1063 |
+
data.forEach((v, i) => i === 0 ? rwCtx.moveTo(xp(i), yp(v)) : rwCtx.lineTo(xp(i), yp(v)));
|
| 1064 |
+
rwCtx.stroke(); rwCtx.restore();
|
| 1065 |
+
}
|
| 1066 |
+
|
| 1067 |
+
const sub = flakeD.slice(0, upTo);
|
| 1068 |
+
if (sub.length > 1) {
|
| 1069 |
+
rwCtx.save(); rwCtx.beginPath();
|
| 1070 |
+
sub.forEach((v, i) => i === 0 ? rwCtx.moveTo(xp(i), yp(v)) : rwCtx.lineTo(xp(i), yp(v)));
|
| 1071 |
+
rwCtx.lineTo(xp(sub.length - 1), pad.t + cH); rwCtx.lineTo(xp(0), pad.t + cH); rwCtx.closePath();
|
| 1072 |
+
const g = rwCtx.createLinearGradient(0, pad.t, 0, pad.t + cH);
|
| 1073 |
+
g.addColorStop(0, 'rgba(245,158,11,.14)'); g.addColorStop(1, 'rgba(245,158,11,.01)');
|
| 1074 |
+
rwCtx.fillStyle = g; rwCtx.fill(); rwCtx.restore();
|
| 1075 |
+
}
|
| 1076 |
+
drawLine(baseD.slice(0, upTo), '#3f3f46', null, 1.5);
|
| 1077 |
+
drawLine(sub, '#f59e0b', 'rgba(245,158,11,.4)', 2);
|
| 1078 |
+
rwCtx.fillStyle = '#52525b'; rwCtx.font = '10px JetBrains Mono'; rwCtx.textAlign = 'center';
|
| 1079 |
+
for (let x = 0; x <= N; x += 10) rwCtx.fillText(x, xp(Math.min(x, N - 1)), H - 6);
|
| 1080 |
+
rwCtx.fillText('Training Episodes', W / 2, H);
|
| 1081 |
+
}
|
| 1082 |
+
drawRwChart(0);
|
| 1083 |
+
const rwObs = new IntersectionObserver(es => {
|
| 1084 |
+
es.forEach(e => {
|
| 1085 |
+
if (e.isIntersecting && rwDrawn === 0) {
|
| 1086 |
+
const iv = setInterval(() => { rwDrawn = Math.min(rwDrawn + 1, N); drawRwChart(rwDrawn); if (rwDrawn >= N) clearInterval(iv); }, 35);
|
| 1087 |
+
rwObs.unobserve(e.target);
|
| 1088 |
+
}
|
| 1089 |
+
});
|
| 1090 |
+
}, { threshold: .3 });
|
| 1091 |
+
rwObs.observe(rwCanvas);
|
| 1092 |
+
window.addEventListener('resize', () => drawRwChart(rwDrawn));
|
| 1093 |
+
|
| 1094 |
+
// CHALLENGE
|
| 1095 |
+
const presets = {
|
| 1096 |
+
race: `import threading\ncounter = 0\n\ndef increment():\n global counter\n counter = counter + 1 # non-atomic!\n\nthreads = [threading.Thread(target=increment) for _ in range(100)]\nfor t in threads: t.start()\nfor t in threads: t.join()\n\ndef test_counter():\n assert counter == 100 # flaky under CPU load`,
|
| 1097 |
+
async: `import asyncio, aiohttp\n\nasync def fetch(session, url):\n async with session.get(url) as r:\n return await r.json()\n\nasync def test_concurrent():\n async with aiohttp.ClientSession() as s:\n a, b = await asyncio.gather(fetch(s,URL_A), fetch(s,URL_B))\n assert a['id'] != b['id'] # flaky \u2014 asyncio.gather + shared session`,
|
| 1098 |
+
db: `import sqlite3\n\ndef test_isolation():\n conn = sqlite3.connect(':memory:')\n conn.execute('CREATE TABLE t (v INT)')\n conn.execute('INSERT INTO t VALUES (42)')\n # missing conn.commit() !\n val = conn.execute('SELECT v FROM t').fetchone()[0]\n assert val == 42`,
|
| 1099 |
+
ext: `import requests\n\ndef test_payment():\n # external sandbox is sometimes slow / down\n r = requests.post('https://sandbox.pay.io/charge',\n json={'amount':100})\n assert r.status_code == 200\n assert r.json()['status'] == 'approved'`,
|
| 1100 |
+
};
|
| 1101 |
+
const chalResps = {
|
| 1102 |
+
race: `FlakeForge Analysis \u2014 cpu_timing_race\n\n[CHAOS_PROBE] CPU stress: failure rate 30% \u2192 100%\n is_infrastructure_sensitive: TRUE\n\n[CAUSAL GRAPH] test \u2192 run_threads \u2192 Thread\u00d7N \u2192 increment() \u2192 counter (global)\n RACE NODE: counter \u2014 non-atomic read-modify-write\n\n[DIAGNOSE] root_cause_file: counter.py\n root_cause_function: increment\n category: TIMING_RACE\n confidence: 0.97\n\n[PATCH APPLIED]\n - global counter\n - counter = counter + 1\n + _lock = threading.Lock()\n + with _lock:\n + counter += 1\n\n[VERIFICATION] pytest --count=20: 20/20 pass\n regression_detected: False\n diff_similarity: 0.94\n\nReward: +7.4 | Category Match | Primitive Match`,
|
| 1103 |
+
async: `FlakeForge Analysis \u2014 async_deadlock\n\n[CHAOS_PROBE] Network delay: failure rate 25% \u2192 88%\n is_infrastructure_sensitive: TRUE\n\n[CAUSAL GRAPH] test \u2192 asyncio.gather \u2192 session (shared) \u2192 CONTENTION NODE\n asyncio event loop + blocking call detected\n\n[DIAGNOSE] root_cause_file: client.py\n root_cause_function: fetch\n category: ASYNC_DEADLOCK\n confidence: 0.91\n\n[PATCH APPLIED]\n - lock = threading.Lock() # wrong primitive for async\n + lock = asyncio.Lock() # correct async primitive\n + async with lock:\n\n[VERIFICATION] 20/20 pass | Reward: +6.8`,
|
| 1104 |
+
db: `FlakeForge Analysis \u2014 db_commit_scope\n\n[CHAOS_PROBE] CPU/NET stress: minimal effect\n is_infrastructure_sensitive: FALSE\n\n[CAUSAL GRAPH] test \u2192 conn.execute(INSERT) \u2192 [NO COMMIT] \u2192 conn.execute(SELECT)\n SCOPE NODE: transaction not committed before read\n\n[DIAGNOSE] root_cause_file: db_ops.py\n root_cause_function: test_isolation\n category: DB_COMMIT_SCOPE\n confidence: 0.99\n\n[PATCH APPLIED]\n conn.execute('INSERT INTO t VALUES (42)')\n + conn.commit() # flush write before read\n val = conn.execute('SELECT v FROM t').fetchone()\n\n[VERIFICATION] 20/20 pass | Reward: +6.5`,
|
| 1105 |
+
ext: `FlakeForge Analysis \u2014 external_dependency\n\n[CHAOS_PROBE] Network delay 500ms: failure rate 20% \u2192 95%\n is_infrastructure_sensitive: TRUE \u2014 external endpoint\n\n[CAUSAL GRAPH] test \u2192 requests.post(sandbox.pay.io) \u2192 EXTERNAL NODE\n Non-determinism source: network / sandbox availability\n\n[DIAGNOSE] root_cause_file: test_payment.py\n root_cause_function: test_payment\n category: EXTERNAL_DEPENDENCY\n confidence: 0.95\n\n[PATCH APPLIED]\n from unittest.mock import patch\n + @patch('requests.post', return_value=MockResponse(200,{'status':'approved'}))\n def test_payment(mock_post):\n r = requests.post(...)\n\n[VERIFICATION] 20/20 pass | Reward: +6.2`,
|
| 1106 |
+
};
|
| 1107 |
+
|
| 1108 |
+
function loadPreset(k) { document.getElementById('chalIn').value = presets[k]; }
|
| 1109 |
+
|
| 1110 |
+
async function runChallenge() {
|
| 1111 |
+
const input = document.getElementById('chalIn').value.trim();
|
| 1112 |
+
if (!input) return;
|
| 1113 |
+
const out = document.getElementById('chalOut');
|
| 1114 |
+
out.style.display = 'block'; out.style.color = 'var(--amber)';
|
| 1115 |
+
const steps = ['[CHAOS_PROBE] amplifying race window...', '[CAUSAL GRAPH] tracing boundary...', '[DIAGNOSE] classifying root cause...', '[PATCH] applying minimal fix...', '[VERIFY] running 20 pytest iterations...'];
|
| 1116 |
+
for (const s of steps) { out.textContent = s; await sleep(650); }
|
| 1117 |
+
let resp = chalResps.race;
|
| 1118 |
+
if (input.includes('asyncio') || input.includes('aiohttp')) resp = chalResps.async;
|
| 1119 |
+
else if (input.includes('sqlite') || input.includes('commit')) resp = chalResps.db;
|
| 1120 |
+
else if (input.includes('requests') || input.includes('sandbox') || input.includes('gateway')) resp = chalResps.ext;
|
| 1121 |
+
out.style.color = 'var(--dim)'; out.textContent = '';
|
| 1122 |
+
for (const ch of resp) { out.textContent += ch; await sleep(6); }
|
| 1123 |
+
}
|
| 1124 |
+
</script>
|
| 1125 |
+
</body>
|
| 1126 |
+
</html>
|
tests/test_action_and_judge_regressions.py
ADDED
|
@@ -0,0 +1,309 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""V3 Regression Tests — Patch Applier and Reward.
|
| 2 |
+
|
| 3 |
+
Tests that were historically problematic in V2 (action dispatch bugs,
|
| 4 |
+
judge scoring inconsistencies) are replaced by V3 equivalents testing
|
| 5 |
+
the patch applier and deterministic reward signals.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
import tempfile
|
| 9 |
+
from pathlib import Path
|
| 10 |
+
|
| 11 |
+
import pytest
|
| 12 |
+
|
| 13 |
+
from models import FlakeForgeAction, FlakeForgeObservation, RunRecord, RewardBreakdown
|
| 14 |
+
from agent.unified_agent import extract_patch, extract_think
|
| 15 |
+
from server.FlakeForge_environment import FlakeForgeEnvironment
|
| 16 |
+
from server.docker_runner import DockerTestRunner
|
| 17 |
+
from server.patch_applier import parse_search_replace_hunks, apply_search_replace_patch
|
| 18 |
+
from server.reward import (
|
| 19 |
+
compute_format_reward,
|
| 20 |
+
compute_anti_hack_penalty,
|
| 21 |
+
compute_stability_reward,
|
| 22 |
+
compute_verifiable_reward,
|
| 23 |
+
)
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
class TestPatchApplierRegressions:
|
| 27 |
+
"""Regression tests for the V3 patch applier."""
|
| 28 |
+
|
| 29 |
+
def test_extracts_patch_from_xml_markdown_fence(self):
|
| 30 |
+
"""Models sometimes wrap the required XML blocks in a Markdown fence."""
|
| 31 |
+
response = """```xml
|
| 32 |
+
<think>
|
| 33 |
+
Root Cause: async_wait (confidence: 0.95)
|
| 34 |
+
Evidence: timeout.
|
| 35 |
+
</think>
|
| 36 |
+
<patch>
|
| 37 |
+
--- source.py
|
| 38 |
+
<<<<<<< SEARCH
|
| 39 |
+
timeout = 0.05
|
| 40 |
+
=======
|
| 41 |
+
timeout = 0.5
|
| 42 |
+
>>>>>>> REPLACE
|
| 43 |
+
</patch>
|
| 44 |
+
```"""
|
| 45 |
+
|
| 46 |
+
assert "Root Cause: async_wait" in extract_think(response)
|
| 47 |
+
assert extract_patch(response) == """--- source.py
|
| 48 |
+
<<<<<<< SEARCH
|
| 49 |
+
timeout = 0.05
|
| 50 |
+
=======
|
| 51 |
+
timeout = 0.5
|
| 52 |
+
>>>>>>> REPLACE"""
|
| 53 |
+
|
| 54 |
+
def test_whitespace_fuzzy_match(self):
|
| 55 |
+
"""Patch with slightly different indentation should still apply."""
|
| 56 |
+
with tempfile.TemporaryDirectory() as tmpdir:
|
| 57 |
+
test_file = Path(tmpdir) / "example.py"
|
| 58 |
+
test_file.write_text(" x = 1\n y = 2\n z = 3\n")
|
| 59 |
+
|
| 60 |
+
# Search text without leading spaces
|
| 61 |
+
patch = """--- example.py
|
| 62 |
+
<<<<<<< SEARCH
|
| 63 |
+
x = 1
|
| 64 |
+
=======
|
| 65 |
+
x = 42
|
| 66 |
+
>>>>>>> REPLACE"""
|
| 67 |
+
result = apply_search_replace_patch(Path(tmpdir), patch)
|
| 68 |
+
# Should succeed via fuzzy match
|
| 69 |
+
assert result["success"] is True
|
| 70 |
+
|
| 71 |
+
def test_file_not_found_graceful(self):
|
| 72 |
+
"""Patching a nonexistent file should not crash."""
|
| 73 |
+
with tempfile.TemporaryDirectory() as tmpdir:
|
| 74 |
+
patch = """--- nonexistent.py
|
| 75 |
+
<<<<<<< SEARCH
|
| 76 |
+
old
|
| 77 |
+
=======
|
| 78 |
+
new
|
| 79 |
+
>>>>>>> REPLACE"""
|
| 80 |
+
result = apply_search_replace_patch(Path(tmpdir), patch)
|
| 81 |
+
assert result["success"] is False
|
| 82 |
+
|
| 83 |
+
def test_empty_search_block_ignored(self):
|
| 84 |
+
"""Empty search blocks should be silently skipped."""
|
| 85 |
+
patch = """--- test.py
|
| 86 |
+
<<<<<<< SEARCH
|
| 87 |
+
|
| 88 |
+
=======
|
| 89 |
+
new_code
|
| 90 |
+
>>>>>>> REPLACE"""
|
| 91 |
+
hunks = parse_search_replace_hunks(patch)
|
| 92 |
+
assert len(hunks) == 0 # Empty search should be skipped
|
| 93 |
+
|
| 94 |
+
def test_multi_hunk_atomicity(self):
|
| 95 |
+
"""If second hunk fails, first should be rolled back."""
|
| 96 |
+
with tempfile.TemporaryDirectory() as tmpdir:
|
| 97 |
+
file1 = Path(tmpdir) / "file1.py"
|
| 98 |
+
file1.write_text("original1 = True\n")
|
| 99 |
+
|
| 100 |
+
patch = """--- file1.py
|
| 101 |
+
<<<<<<< SEARCH
|
| 102 |
+
original1 = True
|
| 103 |
+
=======
|
| 104 |
+
modified1 = True
|
| 105 |
+
>>>>>>> REPLACE
|
| 106 |
+
|
| 107 |
+
--- file2.py
|
| 108 |
+
<<<<<<< SEARCH
|
| 109 |
+
this_doesnt_exist
|
| 110 |
+
=======
|
| 111 |
+
replacement
|
| 112 |
+
>>>>>>> REPLACE"""
|
| 113 |
+
result = apply_search_replace_patch(Path(tmpdir), patch)
|
| 114 |
+
assert result["success"] is False
|
| 115 |
+
|
| 116 |
+
def test_protected_file_patch_is_rejected(self):
|
| 117 |
+
"""Infrastructure/config files should not be patchable by model output."""
|
| 118 |
+
with tempfile.TemporaryDirectory() as tmpdir:
|
| 119 |
+
pyproject = Path(tmpdir) / "pyproject.toml"
|
| 120 |
+
pyproject.write_text("[tool.pytest.ini_options]\n", encoding="utf-8")
|
| 121 |
+
|
| 122 |
+
patch = """--- pyproject.toml
|
| 123 |
+
<<<<<<< SEARCH
|
| 124 |
+
[tool.pytest.ini_options]
|
| 125 |
+
=======
|
| 126 |
+
[tool.pytest.ini_options]
|
| 127 |
+
addopts = "-k not flaky"
|
| 128 |
+
>>>>>>> REPLACE"""
|
| 129 |
+
result = apply_search_replace_patch(Path(tmpdir), patch)
|
| 130 |
+
|
| 131 |
+
assert result["success"] is False
|
| 132 |
+
assert result["protected_file"] is True
|
| 133 |
+
assert "protected_file" in str(result["error"])
|
| 134 |
+
|
| 135 |
+
def test_noop_patch_is_marked(self):
|
| 136 |
+
"""A syntactically applied patch with no diff should be visible to reward."""
|
| 137 |
+
with tempfile.TemporaryDirectory() as tmpdir:
|
| 138 |
+
source = Path(tmpdir) / "source.py"
|
| 139 |
+
source.write_text("value = 1\n", encoding="utf-8")
|
| 140 |
+
|
| 141 |
+
patch = """--- source.py
|
| 142 |
+
<<<<<<< SEARCH
|
| 143 |
+
value = 1
|
| 144 |
+
=======
|
| 145 |
+
value = 1
|
| 146 |
+
>>>>>>> REPLACE"""
|
| 147 |
+
result = apply_search_replace_patch(Path(tmpdir), patch)
|
| 148 |
+
|
| 149 |
+
assert result["success"] is True
|
| 150 |
+
assert result["noop"] is True
|
| 151 |
+
assert result["lines_changed"] == 0
|
| 152 |
+
|
| 153 |
+
|
| 154 |
+
class TestRunnerRegressions:
|
| 155 |
+
"""Regression tests for pytest result parsing."""
|
| 156 |
+
|
| 157 |
+
def test_quiet_pytest_success_counts_as_passed(self):
|
| 158 |
+
"""Quiet pytest output may omit '1 passed', but return code 0 is success."""
|
| 159 |
+
with tempfile.TemporaryDirectory() as tmpdir:
|
| 160 |
+
root = Path(tmpdir)
|
| 161 |
+
tests_dir = root / "tests"
|
| 162 |
+
tests_dir.mkdir()
|
| 163 |
+
(root / "pytest.ini").write_text("[pytest]\naddopts = -q\n", encoding="utf-8")
|
| 164 |
+
(tests_dir / "test_ok.py").write_text(
|
| 165 |
+
"def test_ok():\n assert True\n",
|
| 166 |
+
encoding="utf-8",
|
| 167 |
+
)
|
| 168 |
+
|
| 169 |
+
result = DockerTestRunner(str(root)).run_test("tests/test_ok.py::test_ok")
|
| 170 |
+
|
| 171 |
+
assert result.passed is True
|
| 172 |
+
|
| 173 |
+
|
| 174 |
+
class TestSourceDetectionRegressions:
|
| 175 |
+
"""Regression tests for source-under-test discovery."""
|
| 176 |
+
|
| 177 |
+
def test_src_layout_import_is_read_as_source_under_test(self):
|
| 178 |
+
with tempfile.TemporaryDirectory() as tmpdir:
|
| 179 |
+
root = Path(tmpdir)
|
| 180 |
+
package = root / "src" / "pkg"
|
| 181 |
+
tests_dir = root / "tests"
|
| 182 |
+
package.mkdir(parents=True)
|
| 183 |
+
tests_dir.mkdir()
|
| 184 |
+
(package / "__init__.py").write_text("", encoding="utf-8")
|
| 185 |
+
(package / "service.py").write_text(
|
| 186 |
+
"def fetch():\n return {'success': True}\n",
|
| 187 |
+
encoding="utf-8",
|
| 188 |
+
)
|
| 189 |
+
(tests_dir / "test_service.py").write_text(
|
| 190 |
+
"from pkg.service import fetch\n\n"
|
| 191 |
+
"def test_fetch():\n"
|
| 192 |
+
" assert fetch()['success']\n",
|
| 193 |
+
encoding="utf-8",
|
| 194 |
+
)
|
| 195 |
+
|
| 196 |
+
env = FlakeForgeEnvironment(
|
| 197 |
+
repo_path=str(root),
|
| 198 |
+
test_identifier="tests/test_service.py::test_fetch",
|
| 199 |
+
runner=object(),
|
| 200 |
+
)
|
| 201 |
+
|
| 202 |
+
_, source = env._read_sources()
|
| 203 |
+
|
| 204 |
+
assert "def fetch" in source
|
| 205 |
+
|
| 206 |
+
|
| 207 |
+
class TestRewardRegressions:
|
| 208 |
+
"""Regression tests for V3 reward signals."""
|
| 209 |
+
|
| 210 |
+
def test_no_crash_on_empty_action(self):
|
| 211 |
+
"""Reward computation should handle empty action gracefully."""
|
| 212 |
+
action = FlakeForgeAction(raw_response="")
|
| 213 |
+
reward = compute_format_reward(action)
|
| 214 |
+
assert reward == 0.0
|
| 215 |
+
|
| 216 |
+
def test_stability_edge_case_100_percent(self):
|
| 217 |
+
"""100% pass rate gives terminal bonus."""
|
| 218 |
+
reward = compute_stability_reward(0.5, 1.0)
|
| 219 |
+
assert reward == 2.25
|
| 220 |
+
|
| 221 |
+
def test_anti_hack_assertion_deletion(self):
|
| 222 |
+
"""Deleting assertions should be heavily penalized."""
|
| 223 |
+
patch = """<<<<<<< SEARCH
|
| 224 |
+
assert result == expected
|
| 225 |
+
assert len(data) > 0
|
| 226 |
+
=======
|
| 227 |
+
pass
|
| 228 |
+
>>>>>>> REPLACE"""
|
| 229 |
+
penalty = compute_anti_hack_penalty(patch, ["test.py"], 3)
|
| 230 |
+
assert penalty < -0.5 # Two assertions removed
|
| 231 |
+
|
| 232 |
+
def test_anti_hack_broad_except(self):
|
| 233 |
+
"""Adding bare except: should be penalized."""
|
| 234 |
+
patch = """<<<<<<< SEARCH
|
| 235 |
+
result = func()
|
| 236 |
+
=======
|
| 237 |
+
try:
|
| 238 |
+
result = func()
|
| 239 |
+
except:
|
| 240 |
+
result = None
|
| 241 |
+
>>>>>>> REPLACE"""
|
| 242 |
+
penalty = compute_anti_hack_penalty(patch, ["test.py"], 5)
|
| 243 |
+
assert penalty < 0
|
| 244 |
+
|
| 245 |
+
def test_reward_penalizes_noop_patch(self):
|
| 246 |
+
action = FlakeForgeAction(
|
| 247 |
+
raw_response="...",
|
| 248 |
+
think_text="Root Cause: async_wait (confidence: 0.9)",
|
| 249 |
+
patch_text="<<<<<<< SEARCH\nvalue = 1\n=======\nvalue = 1\n>>>>>>> REPLACE",
|
| 250 |
+
predicted_category="async_wait",
|
| 251 |
+
)
|
| 252 |
+
obs = FlakeForgeObservation(
|
| 253 |
+
episode_id="e",
|
| 254 |
+
test_identifier="tests/test_x.py::test_x",
|
| 255 |
+
step=1,
|
| 256 |
+
steps_remaining=1,
|
| 257 |
+
test_function_source="",
|
| 258 |
+
source_under_test="",
|
| 259 |
+
)
|
| 260 |
+
|
| 261 |
+
reward = compute_verifiable_reward(
|
| 262 |
+
action=action,
|
| 263 |
+
observation=obs,
|
| 264 |
+
patch_result={
|
| 265 |
+
"success": True,
|
| 266 |
+
"files_modified": ["source.py"],
|
| 267 |
+
"lines_changed": 0,
|
| 268 |
+
"noop": True,
|
| 269 |
+
},
|
| 270 |
+
post_run_results=[{"passed": False, "error_type": "AssertionError"}],
|
| 271 |
+
baseline_pass_rate=0.0,
|
| 272 |
+
pre_entropy=0.0,
|
| 273 |
+
)
|
| 274 |
+
|
| 275 |
+
assert reward.noop_patch_penalty == -0.5
|
| 276 |
+
assert reward.to_dict()["noop_patch"] == -0.5
|
| 277 |
+
|
| 278 |
+
def test_reward_penalizes_regression(self):
|
| 279 |
+
action = FlakeForgeAction(
|
| 280 |
+
raw_response="...",
|
| 281 |
+
think_text="Root Cause: nondeterminism (confidence: 0.9)",
|
| 282 |
+
patch_text="<<<<<<< SEARCH\nold\n=======\nnew\n>>>>>>> REPLACE",
|
| 283 |
+
predicted_category="nondeterminism",
|
| 284 |
+
)
|
| 285 |
+
obs = FlakeForgeObservation(
|
| 286 |
+
episode_id="e",
|
| 287 |
+
test_identifier="tests/test_x.py::test_x",
|
| 288 |
+
step=1,
|
| 289 |
+
steps_remaining=1,
|
| 290 |
+
test_function_source="",
|
| 291 |
+
source_under_test="",
|
| 292 |
+
)
|
| 293 |
+
|
| 294 |
+
reward = compute_verifiable_reward(
|
| 295 |
+
action=action,
|
| 296 |
+
observation=obs,
|
| 297 |
+
patch_result={
|
| 298 |
+
"success": False,
|
| 299 |
+
"files_modified": [],
|
| 300 |
+
"lines_changed": 0,
|
| 301 |
+
},
|
| 302 |
+
post_run_results=[{"passed": True, "error_type": None}],
|
| 303 |
+
baseline_pass_rate=0.0,
|
| 304 |
+
pre_entropy=0.0,
|
| 305 |
+
regression_detected=True,
|
| 306 |
+
)
|
| 307 |
+
|
| 308 |
+
assert reward.regression_penalty < 0.0
|
| 309 |
+
assert reward.terminal_bonus == 0.0
|
tests/test_flakeforge_integration.py
ADDED
|
@@ -0,0 +1,473 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""FlakeForge Integration Tests."""
|
| 2 |
+
|
| 3 |
+
import math
|
| 4 |
+
import os
|
| 5 |
+
import tempfile
|
| 6 |
+
from pathlib import Path
|
| 7 |
+
from typing import Any, Dict
|
| 8 |
+
|
| 9 |
+
import pytest
|
| 10 |
+
|
| 11 |
+
from models import (
|
| 12 |
+
FlakeForgeAction,
|
| 13 |
+
FlakeForgeObservation,
|
| 14 |
+
FlakeForgeState,
|
| 15 |
+
RunRecord,
|
| 16 |
+
PatchRecord,
|
| 17 |
+
RewardBreakdown,
|
| 18 |
+
ROOT_CAUSE_TYPES,
|
| 19 |
+
failure_mode_entropy,
|
| 20 |
+
)
|
| 21 |
+
from agent.unified_agent import (
|
| 22 |
+
extract_think,
|
| 23 |
+
extract_patch,
|
| 24 |
+
extract_category_from_think,
|
| 25 |
+
extract_confidence_from_think,
|
| 26 |
+
infer_category_from_patch,
|
| 27 |
+
build_unified_prompt,
|
| 28 |
+
)
|
| 29 |
+
from server.patch_applier import parse_search_replace_hunks, apply_search_replace_patch
|
| 30 |
+
from server.reward import (
|
| 31 |
+
compute_format_reward,
|
| 32 |
+
compute_compile_reward,
|
| 33 |
+
compute_stability_reward,
|
| 34 |
+
compute_causal_proximity_reward,
|
| 35 |
+
compute_entropy_reward,
|
| 36 |
+
compute_anti_hack_penalty,
|
| 37 |
+
compute_reasoning_consistency,
|
| 38 |
+
)
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
class TestThinkPatchParsing:
|
| 42 |
+
"""Test extraction of <think> and <patch> blocks from model output."""
|
| 43 |
+
|
| 44 |
+
def test_extract_both_blocks(self):
|
| 45 |
+
response = """<think>
|
| 46 |
+
Root Cause: async_wait (confidence: 0.85)
|
| 47 |
+
Evidence: TimeoutError at test_flaky.py:12
|
| 48 |
+
Strategy: Increase timeout
|
| 49 |
+
</think>
|
| 50 |
+
|
| 51 |
+
<patch>
|
| 52 |
+
--- tests/test_flaky.py
|
| 53 |
+
<<<<<<< SEARCH
|
| 54 |
+
result = await asyncio.wait_for(fetch(), timeout=0.05)
|
| 55 |
+
=======
|
| 56 |
+
result = await asyncio.wait_for(fetch(), timeout=0.5)
|
| 57 |
+
>>>>>>> REPLACE
|
| 58 |
+
</patch>"""
|
| 59 |
+
assert "Root Cause: async_wait" in extract_think(response)
|
| 60 |
+
assert "<<<<<<< SEARCH" in extract_patch(response)
|
| 61 |
+
|
| 62 |
+
def test_extract_single_json_response(self):
|
| 63 |
+
response = """{
|
| 64 |
+
"think": {
|
| 65 |
+
"claims": [
|
| 66 |
+
{
|
| 67 |
+
"claim_id": "c1",
|
| 68 |
+
"category": "async_wait",
|
| 69 |
+
"entity": "fetch",
|
| 70 |
+
"location": "tests/test_flaky.py::test_fetch",
|
| 71 |
+
"ast_node_type": "Call",
|
| 72 |
+
"polarity": "present",
|
| 73 |
+
"predicted_effect": "The test should stop timing out.",
|
| 74 |
+
"reason": "The wait_for timeout is too aggressive."
|
| 75 |
+
}
|
| 76 |
+
],
|
| 77 |
+
"confidence": 0.85
|
| 78 |
+
},
|
| 79 |
+
"patch": {
|
| 80 |
+
"hunks": [
|
| 81 |
+
{
|
| 82 |
+
"hunk_id": "h1",
|
| 83 |
+
"file": "tests/test_flaky.py",
|
| 84 |
+
"search": " result = await asyncio.wait_for(fetch(), timeout=0.05)",
|
| 85 |
+
"replace": " result = await asyncio.wait_for(fetch(), timeout=0.5)",
|
| 86 |
+
"rationale": "Use a realistic timeout.",
|
| 87 |
+
"addresses_claim": "c1"
|
| 88 |
+
}
|
| 89 |
+
]
|
| 90 |
+
}
|
| 91 |
+
}"""
|
| 92 |
+
assert '"claims"' in extract_think(response)
|
| 93 |
+
patch = extract_patch(response)
|
| 94 |
+
assert "--- tests/test_flaky.py" in patch
|
| 95 |
+
assert "<<<<<<< SEARCH" in patch
|
| 96 |
+
assert ">>>>>>> REPLACE" in patch
|
| 97 |
+
|
| 98 |
+
def test_extract_minimal_json_response(self):
|
| 99 |
+
"""7B-model friendly: only required fields, no optional keys."""
|
| 100 |
+
response = """{
|
| 101 |
+
"think": {
|
| 102 |
+
"claims": [
|
| 103 |
+
{
|
| 104 |
+
"category": "async_wait",
|
| 105 |
+
"entity": "fetch",
|
| 106 |
+
"location": "tests/test_flaky.py::test_fetch",
|
| 107 |
+
"polarity": "present",
|
| 108 |
+
"reason": "Timeout too aggressive."
|
| 109 |
+
}
|
| 110 |
+
],
|
| 111 |
+
"confidence": 0.85
|
| 112 |
+
},
|
| 113 |
+
"patch": {
|
| 114 |
+
"hunks": [
|
| 115 |
+
{
|
| 116 |
+
"file": "tests/test_flaky.py",
|
| 117 |
+
"search": " result = await asyncio.wait_for(fetch(), timeout=0.05)",
|
| 118 |
+
"replace": " result = await asyncio.wait_for(fetch(), timeout=0.5)"
|
| 119 |
+
}
|
| 120 |
+
]
|
| 121 |
+
}
|
| 122 |
+
}"""
|
| 123 |
+
assert '"claims"' in extract_think(response)
|
| 124 |
+
patch = extract_patch(response)
|
| 125 |
+
assert "--- tests/test_flaky.py" in patch
|
| 126 |
+
assert "<<<<<<< SEARCH" in patch
|
| 127 |
+
assert ">>>>>>> REPLACE" in patch
|
| 128 |
+
|
| 129 |
+
def test_extract_patch_normalizes_escaped_hunk_strings(self):
|
| 130 |
+
response = """{
|
| 131 |
+
"think": {
|
| 132 |
+
"claims": [
|
| 133 |
+
{
|
| 134 |
+
"category": "concurrency",
|
| 135 |
+
"entity": "submit",
|
| 136 |
+
"location": "source.py::WorkerPool.submit",
|
| 137 |
+
"polarity": "present",
|
| 138 |
+
"reason": "Lock branch malformed"
|
| 139 |
+
}
|
| 140 |
+
],
|
| 141 |
+
"confidence": 0.85
|
| 142 |
+
},
|
| 143 |
+
"patch": {
|
| 144 |
+
"hunks": [
|
| 145 |
+
{
|
| 146 |
+
"file": "source.py",
|
| 147 |
+
"search": " with self._lock:\\\\\\"",
|
| 148 |
+
"replace": " with self._lock:",
|
| 149 |
+
"rationale": "Normalize malformed line"
|
| 150 |
+
}
|
| 151 |
+
]
|
| 152 |
+
}
|
| 153 |
+
}"""
|
| 154 |
+
patch = extract_patch(response)
|
| 155 |
+
assert "--- source.py" in patch
|
| 156 |
+
assert "with self._lock:" in patch
|
| 157 |
+
assert '\\"' not in patch
|
| 158 |
+
|
| 159 |
+
def test_extract_empty_response(self):
|
| 160 |
+
assert extract_think("") == ""
|
| 161 |
+
assert extract_patch("") == ""
|
| 162 |
+
|
| 163 |
+
def test_extract_malformed_blocks(self):
|
| 164 |
+
response = "No XML blocks here"
|
| 165 |
+
assert extract_think(response) == ""
|
| 166 |
+
assert extract_patch(response) == ""
|
| 167 |
+
|
| 168 |
+
def test_category_extraction(self):
|
| 169 |
+
think = "Root Cause: async_wait (confidence: 0.85)"
|
| 170 |
+
assert extract_category_from_think(think) == "async_wait"
|
| 171 |
+
|
| 172 |
+
def test_category_normalization(self):
|
| 173 |
+
assert extract_category_from_think("Root Cause: timeout") == "async_wait"
|
| 174 |
+
assert extract_category_from_think("Root Cause: race") == "concurrency"
|
| 175 |
+
assert extract_category_from_think("Root Cause: mock") == "mock_residue"
|
| 176 |
+
assert extract_category_from_think("Root Cause: fixture") == "fixture_scope_leak"
|
| 177 |
+
|
| 178 |
+
def test_confidence_extraction(self):
|
| 179 |
+
think = "Root Cause: async_wait (confidence: 0.85)"
|
| 180 |
+
assert extract_confidence_from_think(think) == 0.85
|
| 181 |
+
|
| 182 |
+
def test_confidence_clamping(self):
|
| 183 |
+
assert extract_confidence_from_think("confidence: 1.5") == 1.0
|
| 184 |
+
assert extract_confidence_from_think("confidence: -0.5") == 0.0
|
| 185 |
+
|
| 186 |
+
|
| 187 |
+
class TestPatchParsing:
|
| 188 |
+
"""Test search/replace hunk parsing."""
|
| 189 |
+
|
| 190 |
+
def test_single_hunk(self):
|
| 191 |
+
patch = """--- tests/test_flaky.py
|
| 192 |
+
<<<<<<< SEARCH
|
| 193 |
+
result = await asyncio.wait_for(fetch(), timeout=0.05)
|
| 194 |
+
=======
|
| 195 |
+
result = await asyncio.wait_for(fetch(), timeout=0.5)
|
| 196 |
+
>>>>>>> REPLACE"""
|
| 197 |
+
hunks = parse_search_replace_hunks(patch)
|
| 198 |
+
assert len(hunks) == 1
|
| 199 |
+
assert hunks[0].file_path == "tests/test_flaky.py"
|
| 200 |
+
assert "timeout=0.05" in hunks[0].search_text
|
| 201 |
+
assert "timeout=0.5" in hunks[0].replace_text
|
| 202 |
+
|
| 203 |
+
def test_multiple_hunks(self):
|
| 204 |
+
patch = """--- file1.py
|
| 205 |
+
<<<<<<< SEARCH
|
| 206 |
+
old1
|
| 207 |
+
=======
|
| 208 |
+
new1
|
| 209 |
+
>>>>>>> REPLACE
|
| 210 |
+
|
| 211 |
+
--- file2.py
|
| 212 |
+
<<<<<<< SEARCH
|
| 213 |
+
old2
|
| 214 |
+
=======
|
| 215 |
+
new2
|
| 216 |
+
>>>>>>> REPLACE"""
|
| 217 |
+
hunks = parse_search_replace_hunks(patch)
|
| 218 |
+
assert len(hunks) == 2
|
| 219 |
+
assert hunks[0].file_path == "file1.py"
|
| 220 |
+
assert hunks[1].file_path == "file2.py"
|
| 221 |
+
|
| 222 |
+
def test_empty_patch(self):
|
| 223 |
+
assert parse_search_replace_hunks("") == []
|
| 224 |
+
assert parse_search_replace_hunks(" ") == []
|
| 225 |
+
|
| 226 |
+
def test_apply_patch_to_file(self):
|
| 227 |
+
with tempfile.TemporaryDirectory() as tmpdir:
|
| 228 |
+
# Create a test file
|
| 229 |
+
test_file = Path(tmpdir) / "test_example.py"
|
| 230 |
+
test_file.write_text("x = 1\ny = 2\nz = 3\n")
|
| 231 |
+
|
| 232 |
+
patch = """--- test_example.py
|
| 233 |
+
<<<<<<< SEARCH
|
| 234 |
+
y = 2
|
| 235 |
+
=======
|
| 236 |
+
y = 42
|
| 237 |
+
>>>>>>> REPLACE"""
|
| 238 |
+
result = apply_search_replace_patch(Path(tmpdir), patch)
|
| 239 |
+
assert result["success"] is True
|
| 240 |
+
assert result["lines_changed"] >= 1
|
| 241 |
+
|
| 242 |
+
content = test_file.read_text()
|
| 243 |
+
assert "y = 42" in content
|
| 244 |
+
assert "y = 2" not in content
|
| 245 |
+
|
| 246 |
+
|
| 247 |
+
class TestRewardSignals:
|
| 248 |
+
"""Test individual reward signal computation."""
|
| 249 |
+
|
| 250 |
+
def test_format_reward_perfect(self):
|
| 251 |
+
action = FlakeForgeAction(
|
| 252 |
+
raw_response="...",
|
| 253 |
+
think_text="Root Cause: async_wait (confidence: 0.85)\nEvidence: ...",
|
| 254 |
+
patch_text="<<<<<<< SEARCH\nold\n=======\nnew\n>>>>>>> REPLACE",
|
| 255 |
+
)
|
| 256 |
+
reward = compute_format_reward(action)
|
| 257 |
+
assert reward == 1.0
|
| 258 |
+
|
| 259 |
+
def test_format_reward_missing_think(self):
|
| 260 |
+
action = FlakeForgeAction(
|
| 261 |
+
raw_response="...",
|
| 262 |
+
think_text="",
|
| 263 |
+
patch_text="<<<<<<< SEARCH\nold\n=======\nnew\n>>>>>>> REPLACE",
|
| 264 |
+
)
|
| 265 |
+
reward = compute_format_reward(action)
|
| 266 |
+
assert reward == 0.5 # Only patch block present
|
| 267 |
+
|
| 268 |
+
def test_format_reward_empty(self):
|
| 269 |
+
action = FlakeForgeAction(raw_response="garbage")
|
| 270 |
+
assert compute_format_reward(action) == 0.0
|
| 271 |
+
|
| 272 |
+
def test_compile_reward(self):
|
| 273 |
+
assert compute_compile_reward(True) == 1.0
|
| 274 |
+
assert compute_compile_reward(False) == -1.0
|
| 275 |
+
assert compute_compile_reward(True, "SyntaxError") == -0.5
|
| 276 |
+
|
| 277 |
+
def test_stability_full_fix(self):
|
| 278 |
+
assert compute_stability_reward(0.5, 1.0) == 2.0
|
| 279 |
+
|
| 280 |
+
def test_stability_improvement(self):
|
| 281 |
+
reward = compute_stability_reward(0.3, 0.6)
|
| 282 |
+
assert reward > 0
|
| 283 |
+
|
| 284 |
+
def test_stability_regression(self):
|
| 285 |
+
reward = compute_stability_reward(0.5, 0.2)
|
| 286 |
+
assert reward < 0
|
| 287 |
+
|
| 288 |
+
def test_stability_no_change(self):
|
| 289 |
+
assert compute_stability_reward(0.5, 0.5) == -0.1
|
| 290 |
+
|
| 291 |
+
def test_causal_proximity_same_file(self):
|
| 292 |
+
reward = compute_causal_proximity_reward(
|
| 293 |
+
patch_files=["tests/test_flaky.py"],
|
| 294 |
+
failure_frontier="tests/test_flaky.py:12:test_func",
|
| 295 |
+
call_chain=["test_func"],
|
| 296 |
+
boundary_crossings=[],
|
| 297 |
+
)
|
| 298 |
+
assert reward == 1.0
|
| 299 |
+
|
| 300 |
+
def test_causal_proximity_distant_file(self):
|
| 301 |
+
reward = compute_causal_proximity_reward(
|
| 302 |
+
patch_files=["utils/helpers.py"],
|
| 303 |
+
failure_frontier="tests/test_flaky.py:12:test_func",
|
| 304 |
+
call_chain=["test_func"],
|
| 305 |
+
boundary_crossings=[],
|
| 306 |
+
)
|
| 307 |
+
assert reward < 0
|
| 308 |
+
|
| 309 |
+
def test_entropy_reduction(self):
|
| 310 |
+
reward = compute_entropy_reward(pre_entropy=0.8, post_entropy=0.2)
|
| 311 |
+
assert reward > 0
|
| 312 |
+
|
| 313 |
+
def test_entropy_increase(self):
|
| 314 |
+
reward = compute_entropy_reward(pre_entropy=0.2, post_entropy=0.8)
|
| 315 |
+
assert reward < 0
|
| 316 |
+
|
| 317 |
+
def test_anti_hack_sleep_injection(self):
|
| 318 |
+
patch = """<<<<<<< SEARCH
|
| 319 |
+
result = func()
|
| 320 |
+
=======
|
| 321 |
+
import time
|
| 322 |
+
time.sleep(1)
|
| 323 |
+
result = func()
|
| 324 |
+
>>>>>>> REPLACE"""
|
| 325 |
+
penalty = compute_anti_hack_penalty(patch, ["test.py"], 3)
|
| 326 |
+
assert penalty < 0
|
| 327 |
+
|
| 328 |
+
def test_anti_hack_skip_decorator(self):
|
| 329 |
+
patch = """<<<<<<< SEARCH
|
| 330 |
+
def test_flaky():
|
| 331 |
+
=======
|
| 332 |
+
@pytest.mark.skip
|
| 333 |
+
def test_flaky():
|
| 334 |
+
>>>>>>> REPLACE"""
|
| 335 |
+
penalty = compute_anti_hack_penalty(patch, ["test.py"], 2)
|
| 336 |
+
assert penalty <= -1.0
|
| 337 |
+
|
| 338 |
+
def test_anti_hack_clean_patch(self):
|
| 339 |
+
patch = """<<<<<<< SEARCH
|
| 340 |
+
timeout=0.05
|
| 341 |
+
=======
|
| 342 |
+
timeout=0.5
|
| 343 |
+
>>>>>>> REPLACE"""
|
| 344 |
+
penalty = compute_anti_hack_penalty(patch, ["test.py"], 1)
|
| 345 |
+
assert penalty == 0.0
|
| 346 |
+
|
| 347 |
+
|
| 348 |
+
class TestReasoningConsistency:
|
| 349 |
+
"""Test reasoning-patch consistency verification."""
|
| 350 |
+
|
| 351 |
+
def test_exact_match(self):
|
| 352 |
+
score = compute_reasoning_consistency(
|
| 353 |
+
"async_wait", "async_wait", "root cause analysis", "timeout fix"
|
| 354 |
+
)
|
| 355 |
+
assert score == 0.5
|
| 356 |
+
|
| 357 |
+
def test_related_match(self):
|
| 358 |
+
score = compute_reasoning_consistency(
|
| 359 |
+
"concurrency", "async_wait", "root cause analysis", "timeout fix"
|
| 360 |
+
)
|
| 361 |
+
assert score == 0.25
|
| 362 |
+
|
| 363 |
+
def test_mismatch(self):
|
| 364 |
+
score = compute_reasoning_consistency(
|
| 365 |
+
"network", "fixture_scope_leak", "network issue", "fixture fix"
|
| 366 |
+
)
|
| 367 |
+
assert score == -0.5
|
| 368 |
+
|
| 369 |
+
|
| 370 |
+
class TestCategoryInference:
|
| 371 |
+
"""Test inferring root cause category from what the patch modifies."""
|
| 372 |
+
|
| 373 |
+
def test_infer_async_wait(self):
|
| 374 |
+
assert infer_category_from_patch("timeout=0.5") == "async_wait"
|
| 375 |
+
|
| 376 |
+
def test_infer_concurrency(self):
|
| 377 |
+
assert infer_category_from_patch("asyncio.Lock()") == "concurrency"
|
| 378 |
+
|
| 379 |
+
def test_infer_fixture(self):
|
| 380 |
+
assert infer_category_from_patch("yield fixture_data\n teardown()") == "fixture_scope_leak"
|
| 381 |
+
|
| 382 |
+
def test_infer_mock(self):
|
| 383 |
+
assert infer_category_from_patch("monkeypatch.setattr") == "mock_residue"
|
| 384 |
+
|
| 385 |
+
def test_infer_cache(self):
|
| 386 |
+
assert infer_category_from_patch("cache_clear()") == "module_cache_pollution"
|
| 387 |
+
|
| 388 |
+
|
| 389 |
+
class TestFailureModeEntropy:
|
| 390 |
+
"""Test Shannon entropy calculation."""
|
| 391 |
+
|
| 392 |
+
def test_single_error_type(self):
|
| 393 |
+
runs = [
|
| 394 |
+
RunRecord(passed=False, duration_ms=100, error_type="TimeoutError"),
|
| 395 |
+
RunRecord(passed=False, duration_ms=100, error_type="TimeoutError"),
|
| 396 |
+
]
|
| 397 |
+
assert failure_mode_entropy(runs) == 0.0
|
| 398 |
+
|
| 399 |
+
def test_uniform_error_distribution(self):
|
| 400 |
+
runs = [
|
| 401 |
+
RunRecord(passed=False, duration_ms=100, error_type="TimeoutError"),
|
| 402 |
+
RunRecord(passed=False, duration_ms=100, error_type="AssertionError"),
|
| 403 |
+
]
|
| 404 |
+
assert failure_mode_entropy(runs) == 1.0
|
| 405 |
+
|
| 406 |
+
def test_no_failures(self):
|
| 407 |
+
runs = [
|
| 408 |
+
RunRecord(passed=True, duration_ms=100),
|
| 409 |
+
RunRecord(passed=True, duration_ms=100),
|
| 410 |
+
]
|
| 411 |
+
assert failure_mode_entropy(runs) == 0.0
|
| 412 |
+
|
| 413 |
+
|
| 414 |
+
class TestObservationBuilding:
|
| 415 |
+
"""Test observation construction."""
|
| 416 |
+
|
| 417 |
+
def test_observation_has_deep_signals(self):
|
| 418 |
+
obs = FlakeForgeObservation(
|
| 419 |
+
episode_id="test-001",
|
| 420 |
+
test_identifier="tests/test_flaky.py::test_func",
|
| 421 |
+
step=0,
|
| 422 |
+
steps_remaining=8,
|
| 423 |
+
test_function_source="def test_func(): pass",
|
| 424 |
+
source_under_test="def func(): pass",
|
| 425 |
+
module_cache_violations=["utils.py: @lru_cache on fetch"],
|
| 426 |
+
async_contamination_alive=True,
|
| 427 |
+
)
|
| 428 |
+
assert len(obs.module_cache_violations) == 1
|
| 429 |
+
assert obs.async_contamination_alive is True
|
| 430 |
+
assert obs.step == 0
|
| 431 |
+
|
| 432 |
+
def test_unified_prompt_includes_deep_signals(self):
|
| 433 |
+
obs = FlakeForgeObservation(
|
| 434 |
+
episode_id="test-001",
|
| 435 |
+
test_identifier="tests/test_flaky.py::test_func",
|
| 436 |
+
step=0,
|
| 437 |
+
steps_remaining=8,
|
| 438 |
+
test_function_source="def test_func(): pass",
|
| 439 |
+
source_under_test="def func(): pass",
|
| 440 |
+
module_cache_violations=["utils.py: @lru_cache on compute"],
|
| 441 |
+
mock_residue_sites=["test_flaky.py:10 — patch() without context"],
|
| 442 |
+
)
|
| 443 |
+
prompt = build_unified_prompt(obs)
|
| 444 |
+
assert "Module cache violations" in prompt
|
| 445 |
+
assert "Mock residue sites" in prompt
|
| 446 |
+
|
| 447 |
+
def test_observation_run_history_limit(self):
|
| 448 |
+
runs = [RunRecord(passed=True, duration_ms=100) for _ in range(30)]
|
| 449 |
+
obs = FlakeForgeObservation(
|
| 450 |
+
episode_id="test-001",
|
| 451 |
+
test_identifier="test",
|
| 452 |
+
step=0,
|
| 453 |
+
steps_remaining=8,
|
| 454 |
+
test_function_source="",
|
| 455 |
+
source_under_test="",
|
| 456 |
+
run_history=runs,
|
| 457 |
+
)
|
| 458 |
+
assert len(obs.run_history) == 20 # Capped at 20
|
| 459 |
+
|
| 460 |
+
|
| 461 |
+
class TestRootCauseTypes:
|
| 462 |
+
"""Verify root cause taxonomy is complete."""
|
| 463 |
+
|
| 464 |
+
def test_all_categories_present(self):
|
| 465 |
+
assert "async_wait" in ROOT_CAUSE_TYPES
|
| 466 |
+
assert "module_cache_pollution" in ROOT_CAUSE_TYPES
|
| 467 |
+
assert "fixture_scope_leak" in ROOT_CAUSE_TYPES
|
| 468 |
+
assert "mock_residue" in ROOT_CAUSE_TYPES
|
| 469 |
+
assert "import_side_effect" in ROOT_CAUSE_TYPES
|
| 470 |
+
assert "unknown" in ROOT_CAUSE_TYPES
|
| 471 |
+
|
| 472 |
+
def test_category_count(self):
|
| 473 |
+
assert len(ROOT_CAUSE_TYPES) == 13
|
tests/visualize_agent_tool_usage.py
ADDED
|
@@ -0,0 +1,197 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Visualize how FlakeForge tool evidence is produced and fed to the agent.
|
| 2 |
+
|
| 3 |
+
This script does not require Docker. It uses a lightweight demo runner that
|
| 4 |
+
alternates pass/fail outcomes and emits traceback-like stderr so the same
|
| 5 |
+
pipeline pieces are exercised:
|
| 6 |
+
- preflight classification
|
| 7 |
+
- deep flakiness signals
|
| 8 |
+
- causal frontier extraction
|
| 9 |
+
- tools-based file targeting hints
|
| 10 |
+
- final prompt section shown to the agent
|
| 11 |
+
|
| 12 |
+
Usage:
|
| 13 |
+
c:/CodingNest/FlakeForge/venv/Scripts/python.exe tests/visualize_agent_tool_usage.py
|
| 14 |
+
c:/CodingNest/FlakeForge/venv/Scripts/python.exe tests/visualize_agent_tool_usage.py --repo-path test_repos/moderate_load_jitter_flaky --test-id tests/test_flaky.py::test_request_processing_should_succeed
|
| 15 |
+
"""
|
| 16 |
+
|
| 17 |
+
from __future__ import annotations
|
| 18 |
+
|
| 19 |
+
import argparse
|
| 20 |
+
import json
|
| 21 |
+
import os
|
| 22 |
+
import sys
|
| 23 |
+
from pathlib import Path
|
| 24 |
+
from typing import Optional
|
| 25 |
+
|
| 26 |
+
# Ensure project root imports resolve when running as a script.
|
| 27 |
+
PROJECT_ROOT = Path(__file__).resolve().parents[1]
|
| 28 |
+
if str(PROJECT_ROOT) not in sys.path:
|
| 29 |
+
sys.path.insert(0, str(PROJECT_ROOT))
|
| 30 |
+
|
| 31 |
+
from agent.unified_agent import build_unified_prompt
|
| 32 |
+
from models import RunRecord
|
| 33 |
+
from server.FlakeForge_environment import FlakeForgeEnvironment
|
| 34 |
+
from server.tools import build_agent_targeting_hints
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
class DemoRunner:
|
| 38 |
+
"""Deterministic alternating pass/fail runner for visualization only."""
|
| 39 |
+
|
| 40 |
+
def __init__(self, repo_path: Path, test_identifier: str) -> None:
|
| 41 |
+
self.repo_path = repo_path
|
| 42 |
+
self.test_identifier = test_identifier
|
| 43 |
+
self.counter = 0
|
| 44 |
+
|
| 45 |
+
def run_test(self, test_identifier: str) -> RunRecord:
|
| 46 |
+
self.counter += 1
|
| 47 |
+
test_file = test_identifier.split("::", 1)[0]
|
| 48 |
+
source_file = self._best_source_candidate(test_file)
|
| 49 |
+
|
| 50 |
+
# Alternate outcomes so preflight can classify as flaky.
|
| 51 |
+
passed = (self.counter % 2 == 0)
|
| 52 |
+
if passed:
|
| 53 |
+
return RunRecord(
|
| 54 |
+
passed=True,
|
| 55 |
+
duration_ms=40 + (self.counter % 7),
|
| 56 |
+
error_type=None,
|
| 57 |
+
error_message=None,
|
| 58 |
+
stderr_excerpt=None,
|
| 59 |
+
)
|
| 60 |
+
|
| 61 |
+
trace = (
|
| 62 |
+
"Traceback (most recent call last):\n"
|
| 63 |
+
f" File \"{(self.repo_path / test_file).as_posix()}\", line 42, in test_case\n"
|
| 64 |
+
f" File \"{(self.repo_path / source_file).as_posix()}\", line 21, in process_request\n"
|
| 65 |
+
"AssertionError: transient mismatch\n"
|
| 66 |
+
)
|
| 67 |
+
return RunRecord(
|
| 68 |
+
passed=False,
|
| 69 |
+
duration_ms=95 + (self.counter % 13),
|
| 70 |
+
error_type="AssertionError",
|
| 71 |
+
error_message="transient mismatch",
|
| 72 |
+
stderr_excerpt=trace,
|
| 73 |
+
)
|
| 74 |
+
|
| 75 |
+
def _best_source_candidate(self, test_file: str) -> str:
|
| 76 |
+
direct = self.repo_path / "source.py"
|
| 77 |
+
if direct.exists():
|
| 78 |
+
return "source.py"
|
| 79 |
+
|
| 80 |
+
# Fallback: any .py file that is not a test file.
|
| 81 |
+
for path in self.repo_path.rglob("*.py"):
|
| 82 |
+
rel = path.relative_to(self.repo_path).as_posix()
|
| 83 |
+
if rel.startswith("tests/"):
|
| 84 |
+
continue
|
| 85 |
+
return rel
|
| 86 |
+
|
| 87 |
+
return test_file
|
| 88 |
+
|
| 89 |
+
|
| 90 |
+
def _extract_targeting_section(prompt: str) -> str:
|
| 91 |
+
header = "=== TARGETING HINTS ==="
|
| 92 |
+
if header not in prompt:
|
| 93 |
+
return "(TARGETING HINTS section not found in prompt)"
|
| 94 |
+
|
| 95 |
+
lines = prompt.splitlines()
|
| 96 |
+
start = None
|
| 97 |
+
for i, line in enumerate(lines):
|
| 98 |
+
if line.strip() == header:
|
| 99 |
+
start = i
|
| 100 |
+
break
|
| 101 |
+
if start is None:
|
| 102 |
+
return "(TARGETING HINTS section not found in prompt)"
|
| 103 |
+
|
| 104 |
+
collected = []
|
| 105 |
+
for line in lines[start:]:
|
| 106 |
+
if line.startswith("=== ") and line.strip() != header and collected:
|
| 107 |
+
break
|
| 108 |
+
collected.append(line)
|
| 109 |
+
return "\n".join(collected)
|
| 110 |
+
|
| 111 |
+
|
| 112 |
+
def main() -> None:
|
| 113 |
+
parser = argparse.ArgumentParser(description="Visualize FlakeForge tool-driven file targeting")
|
| 114 |
+
parser.add_argument(
|
| 115 |
+
"--repo-path",
|
| 116 |
+
default=os.environ.get("FF_REPO_PATH", "test_repos/moderate_load_jitter_flaky"),
|
| 117 |
+
help="Repo path used for environment reset",
|
| 118 |
+
)
|
| 119 |
+
parser.add_argument(
|
| 120 |
+
"--test-id",
|
| 121 |
+
default=os.environ.get("FF_TEST_ID", "tests/test_flaky.py::test_request_processing_should_succeed"),
|
| 122 |
+
help="Test identifier",
|
| 123 |
+
)
|
| 124 |
+
parser.add_argument(
|
| 125 |
+
"--quick-runs",
|
| 126 |
+
type=int,
|
| 127 |
+
default=6,
|
| 128 |
+
help="Preflight quick runs",
|
| 129 |
+
)
|
| 130 |
+
parser.add_argument(
|
| 131 |
+
"--confirm-runs",
|
| 132 |
+
type=int,
|
| 133 |
+
default=6,
|
| 134 |
+
help="Preflight confirm runs",
|
| 135 |
+
)
|
| 136 |
+
args = parser.parse_args()
|
| 137 |
+
|
| 138 |
+
repo_path = Path(args.repo_path)
|
| 139 |
+
runner = DemoRunner(repo_path=repo_path, test_identifier=args.test_id)
|
| 140 |
+
|
| 141 |
+
env = FlakeForgeEnvironment(
|
| 142 |
+
repo_path=str(repo_path),
|
| 143 |
+
test_identifier=args.test_id,
|
| 144 |
+
runner=runner,
|
| 145 |
+
max_steps=3,
|
| 146 |
+
num_runs=8,
|
| 147 |
+
)
|
| 148 |
+
|
| 149 |
+
obs = env.reset(
|
| 150 |
+
preflight_quick_runs=args.quick_runs,
|
| 151 |
+
preflight_confirm_runs=args.confirm_runs,
|
| 152 |
+
drop_deterministic_bugs=False,
|
| 153 |
+
)
|
| 154 |
+
|
| 155 |
+
print("\n=== PREFLIGHT SUMMARY ===")
|
| 156 |
+
print(json.dumps(obs.preflight_result, indent=2))
|
| 157 |
+
|
| 158 |
+
print("\n=== DEEP SIGNAL SNAPSHOT ===")
|
| 159 |
+
deep_counts = {
|
| 160 |
+
"module_cache_violations": len(obs.module_cache_violations),
|
| 161 |
+
"fixture_scope_risks": len(obs.fixture_scope_risks),
|
| 162 |
+
"mock_residue_sites": len(obs.mock_residue_sites),
|
| 163 |
+
"import_side_effect_files": len(obs.import_side_effect_files),
|
| 164 |
+
"async_contamination_alive": bool(obs.async_contamination_alive),
|
| 165 |
+
}
|
| 166 |
+
print(json.dumps(deep_counts, indent=2))
|
| 167 |
+
|
| 168 |
+
print("\n=== MERGED TARGETING HINTS IN OBSERVATION ===")
|
| 169 |
+
for idx, hint in enumerate(obs.causal_hints[:10], start=1):
|
| 170 |
+
print(f"{idx:02d}. {hint}")
|
| 171 |
+
|
| 172 |
+
extra_hints = build_agent_targeting_hints(
|
| 173 |
+
repo_path=str(repo_path),
|
| 174 |
+
test_identifier=obs.test_identifier,
|
| 175 |
+
failing_stack_trace=obs.failing_stack_trace,
|
| 176 |
+
source_under_test=obs.source_under_test,
|
| 177 |
+
causal_frontier=obs.failure_frontier,
|
| 178 |
+
deep_signals={
|
| 179 |
+
"module_cache_violations": obs.module_cache_violations,
|
| 180 |
+
"fixture_scope_risks": obs.fixture_scope_risks,
|
| 181 |
+
"mock_residue_sites": obs.mock_residue_sites,
|
| 182 |
+
"import_side_effect_files": obs.import_side_effect_files,
|
| 183 |
+
},
|
| 184 |
+
max_hints=8,
|
| 185 |
+
)
|
| 186 |
+
|
| 187 |
+
print("\n=== TOOLS-ONLY TARGETING HINTS (DIRECT CALL) ===")
|
| 188 |
+
for idx, hint in enumerate(extra_hints, start=1):
|
| 189 |
+
print(f"{idx:02d}. {hint}")
|
| 190 |
+
|
| 191 |
+
prompt = build_unified_prompt(obs)
|
| 192 |
+
print("\n=== PROMPT TARGETING SECTION SEEN BY AGENT ===")
|
| 193 |
+
print(_extract_targeting_section(prompt))
|
| 194 |
+
|
| 195 |
+
|
| 196 |
+
if __name__ == "__main__":
|
| 197 |
+
main()
|
tests/visualize_causal_graph_usage.py
ADDED
|
@@ -0,0 +1,189 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Visualize exactly how causal graph output is used by FlakeForge.
|
| 2 |
+
|
| 3 |
+
This script shows five layers side-by-side:
|
| 4 |
+
1) Raw causal graph dictionary (nodes/edges/boundaries)
|
| 5 |
+
2) Raw causal hints from CrossRepoGraphBuilder (boundary warnings)
|
| 6 |
+
3) Tools-based targeting hints (stack trace/import/deep signals)
|
| 7 |
+
4) Merged hints stored in observation.causal_hints
|
| 8 |
+
5) Final TARGETING HINTS section that the agent sees in its prompt
|
| 9 |
+
|
| 10 |
+
Usage:
|
| 11 |
+
c:/CodingNest/FlakeForge/venv/Scripts/python.exe tests/visualize_causal_graph_usage.py
|
| 12 |
+
c:/CodingNest/FlakeForge/venv/Scripts/python.exe tests/visualize_causal_graph_usage.py --repo-path test_repos/moderate_load_jitter_flaky --test-id tests/test_flaky.py::test_request_processing_should_succeed
|
| 13 |
+
"""
|
| 14 |
+
|
| 15 |
+
from __future__ import annotations
|
| 16 |
+
|
| 17 |
+
import argparse
|
| 18 |
+
import json
|
| 19 |
+
import os
|
| 20 |
+
import sys
|
| 21 |
+
from pathlib import Path
|
| 22 |
+
from typing import List
|
| 23 |
+
|
| 24 |
+
PROJECT_ROOT = Path(__file__).resolve().parents[1]
|
| 25 |
+
if str(PROJECT_ROOT) not in sys.path:
|
| 26 |
+
sys.path.insert(0, str(PROJECT_ROOT))
|
| 27 |
+
|
| 28 |
+
from agent.unified_agent import build_unified_prompt
|
| 29 |
+
from models import RunRecord
|
| 30 |
+
from server.FlakeForge_environment import FlakeForgeEnvironment
|
| 31 |
+
from server.tools import build_agent_targeting_hints
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
class DemoRunner:
|
| 35 |
+
"""Alternating pass/fail runner so reset builds realistic failure context."""
|
| 36 |
+
|
| 37 |
+
def __init__(self, repo_path: Path) -> None:
|
| 38 |
+
self.repo_path = repo_path
|
| 39 |
+
self.counter = 0
|
| 40 |
+
|
| 41 |
+
def run_test(self, test_identifier: str) -> RunRecord:
|
| 42 |
+
self.counter += 1
|
| 43 |
+
test_file = test_identifier.split("::", 1)[0]
|
| 44 |
+
src = self._best_source_candidate()
|
| 45 |
+
|
| 46 |
+
if self.counter % 2 == 0:
|
| 47 |
+
return RunRecord(
|
| 48 |
+
passed=True,
|
| 49 |
+
duration_ms=35 + (self.counter % 9),
|
| 50 |
+
error_type=None,
|
| 51 |
+
error_message=None,
|
| 52 |
+
stderr_excerpt=None,
|
| 53 |
+
)
|
| 54 |
+
|
| 55 |
+
trace = (
|
| 56 |
+
"Traceback (most recent call last):\n"
|
| 57 |
+
f" File \"{(self.repo_path / test_file).as_posix()}\", line 28, in target_test\n"
|
| 58 |
+
f" File \"{(self.repo_path / src).as_posix()}\", line 17, in process_request\n"
|
| 59 |
+
"AssertionError: intermittent behavior\n"
|
| 60 |
+
)
|
| 61 |
+
return RunRecord(
|
| 62 |
+
passed=False,
|
| 63 |
+
duration_ms=80 + (self.counter % 11),
|
| 64 |
+
error_type="AssertionError",
|
| 65 |
+
error_message="intermittent behavior",
|
| 66 |
+
stderr_excerpt=trace,
|
| 67 |
+
)
|
| 68 |
+
|
| 69 |
+
def _best_source_candidate(self) -> str:
|
| 70 |
+
if (self.repo_path / "source.py").exists():
|
| 71 |
+
return "source.py"
|
| 72 |
+
for path in self.repo_path.rglob("*.py"):
|
| 73 |
+
rel = path.relative_to(self.repo_path).as_posix()
|
| 74 |
+
if not rel.startswith("tests/"):
|
| 75 |
+
return rel
|
| 76 |
+
return "tests/test_flaky.py"
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
def _extract_section(prompt: str, header: str) -> str:
|
| 80 |
+
lines = prompt.splitlines()
|
| 81 |
+
start = None
|
| 82 |
+
for i, line in enumerate(lines):
|
| 83 |
+
if line.strip() == header:
|
| 84 |
+
start = i
|
| 85 |
+
break
|
| 86 |
+
if start is None:
|
| 87 |
+
return f"({header} section not found)"
|
| 88 |
+
|
| 89 |
+
out: List[str] = []
|
| 90 |
+
for line in lines[start:]:
|
| 91 |
+
if line.startswith("=== ") and line.strip() != header and out:
|
| 92 |
+
break
|
| 93 |
+
out.append(line)
|
| 94 |
+
return "\n".join(out)
|
| 95 |
+
|
| 96 |
+
|
| 97 |
+
def main() -> None:
|
| 98 |
+
parser = argparse.ArgumentParser(description="Visualize causal graph usage in FlakeForge")
|
| 99 |
+
parser.add_argument(
|
| 100 |
+
"--repo-path",
|
| 101 |
+
default=os.environ.get("FF_REPO_PATH", "test_repos/moderate_load_jitter_flaky"),
|
| 102 |
+
help="Repo path",
|
| 103 |
+
)
|
| 104 |
+
parser.add_argument(
|
| 105 |
+
"--test-id",
|
| 106 |
+
default=os.environ.get("FF_TEST_ID", "tests/test_flaky.py::test_request_processing_should_succeed"),
|
| 107 |
+
help="Test identifier",
|
| 108 |
+
)
|
| 109 |
+
parser.add_argument("--quick-runs", type=int, default=4, help="Preflight quick runs")
|
| 110 |
+
parser.add_argument("--confirm-runs", type=int, default=4, help="Preflight confirm runs")
|
| 111 |
+
args = parser.parse_args()
|
| 112 |
+
|
| 113 |
+
repo_path = Path(args.repo_path)
|
| 114 |
+
env = FlakeForgeEnvironment(
|
| 115 |
+
repo_path=str(repo_path),
|
| 116 |
+
test_identifier=args.test_id,
|
| 117 |
+
runner=DemoRunner(repo_path),
|
| 118 |
+
max_steps=3,
|
| 119 |
+
num_runs=8,
|
| 120 |
+
)
|
| 121 |
+
|
| 122 |
+
obs = env.reset(
|
| 123 |
+
preflight_quick_runs=args.quick_runs,
|
| 124 |
+
preflight_confirm_runs=args.confirm_runs,
|
| 125 |
+
drop_deterministic_bugs=False,
|
| 126 |
+
)
|
| 127 |
+
|
| 128 |
+
# Raw causal graph output from reset observation.
|
| 129 |
+
causal_graph = obs.causal_graph or {}
|
| 130 |
+
raw_boundary_hints = list(causal_graph.get("boundary_warnings", []))
|
| 131 |
+
|
| 132 |
+
# Call builder directly to show underlying raw hints source.
|
| 133 |
+
direct_graph, direct_hints = env._build_causal_graph(obs.test_function_source)
|
| 134 |
+
|
| 135 |
+
tool_hints = build_agent_targeting_hints(
|
| 136 |
+
repo_path=str(repo_path),
|
| 137 |
+
test_identifier=obs.test_identifier,
|
| 138 |
+
failing_stack_trace=obs.failing_stack_trace,
|
| 139 |
+
source_under_test=obs.source_under_test,
|
| 140 |
+
causal_frontier=obs.failure_frontier,
|
| 141 |
+
deep_signals={
|
| 142 |
+
"module_cache_violations": obs.module_cache_violations,
|
| 143 |
+
"fixture_scope_risks": obs.fixture_scope_risks,
|
| 144 |
+
"mock_residue_sites": obs.mock_residue_sites,
|
| 145 |
+
"import_side_effect_files": obs.import_side_effect_files,
|
| 146 |
+
},
|
| 147 |
+
max_hints=8,
|
| 148 |
+
)
|
| 149 |
+
|
| 150 |
+
merged_recomputed = list(dict.fromkeys([*direct_hints, *tool_hints]))[:10]
|
| 151 |
+
|
| 152 |
+
print("\n=== 1) RAW CAUSAL GRAPH (observation.causal_graph) ===")
|
| 153 |
+
print(json.dumps(causal_graph, indent=2))
|
| 154 |
+
|
| 155 |
+
print("\n=== 2) RAW CAUSAL HINTS (from causal graph boundary warnings) ===")
|
| 156 |
+
if raw_boundary_hints:
|
| 157 |
+
for i, hint in enumerate(raw_boundary_hints, 1):
|
| 158 |
+
print(f"{i:02d}. {hint}")
|
| 159 |
+
else:
|
| 160 |
+
print("(none)")
|
| 161 |
+
|
| 162 |
+
print("\n=== 3) DIRECT BUILDER OUTPUT (for verification) ===")
|
| 163 |
+
print("Raw hints from env._build_causal_graph():")
|
| 164 |
+
if direct_hints:
|
| 165 |
+
for i, hint in enumerate(direct_hints, 1):
|
| 166 |
+
print(f"{i:02d}. {hint}")
|
| 167 |
+
else:
|
| 168 |
+
print("(none)")
|
| 169 |
+
print("Graph has nodes:", len((direct_graph or {}).get("nodes", [])) if direct_graph else 0)
|
| 170 |
+
print("Graph has edges:", len((direct_graph or {}).get("edges", [])) if direct_graph else 0)
|
| 171 |
+
|
| 172 |
+
print("\n=== 4) TOOLS HINTS (non-causal) ===")
|
| 173 |
+
for i, hint in enumerate(tool_hints, 1):
|
| 174 |
+
print(f"{i:02d}. {hint}")
|
| 175 |
+
|
| 176 |
+
print("\n=== 5) MERGED HINTS USED BY ENV ===")
|
| 177 |
+
for i, hint in enumerate(obs.causal_hints, 1):
|
| 178 |
+
print(f"{i:02d}. {hint}")
|
| 179 |
+
|
| 180 |
+
print("\n=== 6) RECOMPUTED MERGE CHECK ===")
|
| 181 |
+
print("Matches observation.causal_hints:", obs.causal_hints == merged_recomputed)
|
| 182 |
+
|
| 183 |
+
prompt = build_unified_prompt(obs)
|
| 184 |
+
print("\n=== 7) PROMPT SECTION FED TO AGENT ===")
|
| 185 |
+
print(_extract_section(prompt, "=== TARGETING HINTS ==="))
|
| 186 |
+
|
| 187 |
+
|
| 188 |
+
if __name__ == "__main__":
|
| 189 |
+
main()
|
tests/visualize_deep_flakiness_usage.py
ADDED
|
@@ -0,0 +1,198 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Visualize exactly how deep flakiness signals are used in FlakeForge.
|
| 2 |
+
|
| 3 |
+
This script demonstrates the complete path:
|
| 4 |
+
1) Raw detector output from server.deep_flakiness.build_deep_observation_signals
|
| 5 |
+
2) Deep signal fields stored in observation
|
| 6 |
+
3) Influence on tools-based file targeting hints
|
| 7 |
+
4) Final prompt sections fed to the agent
|
| 8 |
+
|
| 9 |
+
Usage:
|
| 10 |
+
c:/CodingNest/FlakeForge/venv/Scripts/python.exe tests/visualize_deep_flakiness_usage.py
|
| 11 |
+
c:/CodingNest/FlakeForge/venv/Scripts/python.exe tests/visualize_deep_flakiness_usage.py --repo-path test_repos/moderate_load_jitter_flaky --test-id tests/test_flaky.py::test_request_processing_should_succeed
|
| 12 |
+
"""
|
| 13 |
+
|
| 14 |
+
from __future__ import annotations
|
| 15 |
+
|
| 16 |
+
import argparse
|
| 17 |
+
import json
|
| 18 |
+
import os
|
| 19 |
+
import sys
|
| 20 |
+
from pathlib import Path
|
| 21 |
+
from typing import List
|
| 22 |
+
|
| 23 |
+
PROJECT_ROOT = Path(__file__).resolve().parents[1]
|
| 24 |
+
if str(PROJECT_ROOT) not in sys.path:
|
| 25 |
+
sys.path.insert(0, str(PROJECT_ROOT))
|
| 26 |
+
|
| 27 |
+
from agent.unified_agent import build_unified_prompt
|
| 28 |
+
from models import RunRecord
|
| 29 |
+
from server.FlakeForge_environment import FlakeForgeEnvironment
|
| 30 |
+
from server.deep_flakiness import build_deep_observation_signals
|
| 31 |
+
from server.tools import build_agent_targeting_hints
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
class DemoRunner:
|
| 35 |
+
"""Alternating pass/fail runner so reset captures failing traces."""
|
| 36 |
+
|
| 37 |
+
def __init__(self, repo_path: Path) -> None:
|
| 38 |
+
self.repo_path = repo_path
|
| 39 |
+
self.counter = 0
|
| 40 |
+
|
| 41 |
+
def run_test(self, test_identifier: str) -> RunRecord:
|
| 42 |
+
self.counter += 1
|
| 43 |
+
test_file = test_identifier.split("::", 1)[0]
|
| 44 |
+
source_file = self._source_candidate()
|
| 45 |
+
|
| 46 |
+
if self.counter % 2 == 0:
|
| 47 |
+
return RunRecord(
|
| 48 |
+
passed=True,
|
| 49 |
+
duration_ms=30 + (self.counter % 10),
|
| 50 |
+
error_type=None,
|
| 51 |
+
error_message=None,
|
| 52 |
+
stderr_excerpt=None,
|
| 53 |
+
)
|
| 54 |
+
|
| 55 |
+
trace = (
|
| 56 |
+
"Traceback (most recent call last):\n"
|
| 57 |
+
f" File \"{(self.repo_path / test_file).as_posix()}\", line 35, in flaky_test\n"
|
| 58 |
+
f" File \"{(self.repo_path / source_file).as_posix()}\", line 20, in process_request\n"
|
| 59 |
+
"AssertionError: unstable result\n"
|
| 60 |
+
)
|
| 61 |
+
return RunRecord(
|
| 62 |
+
passed=False,
|
| 63 |
+
duration_ms=90 + (self.counter % 12),
|
| 64 |
+
error_type="AssertionError",
|
| 65 |
+
error_message="unstable result",
|
| 66 |
+
stderr_excerpt=trace,
|
| 67 |
+
)
|
| 68 |
+
|
| 69 |
+
def _source_candidate(self) -> str:
|
| 70 |
+
if (self.repo_path / "source.py").exists():
|
| 71 |
+
return "source.py"
|
| 72 |
+
for path in self.repo_path.rglob("*.py"):
|
| 73 |
+
rel = path.relative_to(self.repo_path).as_posix()
|
| 74 |
+
if not rel.startswith("tests/"):
|
| 75 |
+
return rel
|
| 76 |
+
return "tests/test_flaky.py"
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
def _extract_section(prompt: str, header: str) -> str:
|
| 80 |
+
lines = prompt.splitlines()
|
| 81 |
+
start = None
|
| 82 |
+
for i, line in enumerate(lines):
|
| 83 |
+
if line.strip() == header:
|
| 84 |
+
start = i
|
| 85 |
+
break
|
| 86 |
+
if start is None:
|
| 87 |
+
return f"({header} section not found)"
|
| 88 |
+
|
| 89 |
+
out: List[str] = []
|
| 90 |
+
for line in lines[start:]:
|
| 91 |
+
if line.startswith("=== ") and line.strip() != header and out:
|
| 92 |
+
break
|
| 93 |
+
out.append(line)
|
| 94 |
+
return "\n".join(out)
|
| 95 |
+
|
| 96 |
+
|
| 97 |
+
def main() -> None:
|
| 98 |
+
parser = argparse.ArgumentParser(description="Visualize deep flakiness signal usage in FlakeForge")
|
| 99 |
+
parser.add_argument(
|
| 100 |
+
"--repo-path",
|
| 101 |
+
default=os.environ.get("FF_REPO_PATH", "test_repos/moderate_load_jitter_flaky"),
|
| 102 |
+
help="Repo path",
|
| 103 |
+
)
|
| 104 |
+
parser.add_argument(
|
| 105 |
+
"--test-id",
|
| 106 |
+
default=os.environ.get("FF_TEST_ID", "tests/test_flaky.py::test_request_processing_should_succeed"),
|
| 107 |
+
help="Test identifier",
|
| 108 |
+
)
|
| 109 |
+
parser.add_argument("--quick-runs", type=int, default=4, help="Preflight quick runs")
|
| 110 |
+
parser.add_argument("--confirm-runs", type=int, default=4, help="Preflight confirm runs")
|
| 111 |
+
args = parser.parse_args()
|
| 112 |
+
|
| 113 |
+
repo_path = Path(args.repo_path)
|
| 114 |
+
|
| 115 |
+
# 1) Raw detector output (direct call)
|
| 116 |
+
raw_deep = build_deep_observation_signals(repo_path)
|
| 117 |
+
|
| 118 |
+
# 2) Through environment reset -> observation
|
| 119 |
+
env = FlakeForgeEnvironment(
|
| 120 |
+
repo_path=str(repo_path),
|
| 121 |
+
test_identifier=args.test_id,
|
| 122 |
+
runner=DemoRunner(repo_path),
|
| 123 |
+
max_steps=3,
|
| 124 |
+
num_runs=8,
|
| 125 |
+
)
|
| 126 |
+
obs = env.reset(
|
| 127 |
+
preflight_quick_runs=args.quick_runs,
|
| 128 |
+
preflight_confirm_runs=args.confirm_runs,
|
| 129 |
+
drop_deterministic_bugs=False,
|
| 130 |
+
)
|
| 131 |
+
|
| 132 |
+
obs_deep = {
|
| 133 |
+
"module_cache_violations": obs.module_cache_violations,
|
| 134 |
+
"fixture_scope_risks": obs.fixture_scope_risks,
|
| 135 |
+
"mock_residue_sites": obs.mock_residue_sites,
|
| 136 |
+
"import_side_effect_files": obs.import_side_effect_files,
|
| 137 |
+
"async_contamination_alive": obs.async_contamination_alive,
|
| 138 |
+
}
|
| 139 |
+
|
| 140 |
+
# 3) Influence on targeting hints
|
| 141 |
+
hints_with_deep = build_agent_targeting_hints(
|
| 142 |
+
repo_path=str(repo_path),
|
| 143 |
+
test_identifier=obs.test_identifier,
|
| 144 |
+
failing_stack_trace=obs.failing_stack_trace,
|
| 145 |
+
source_under_test=obs.source_under_test,
|
| 146 |
+
causal_frontier=obs.failure_frontier,
|
| 147 |
+
deep_signals=obs_deep,
|
| 148 |
+
max_hints=8,
|
| 149 |
+
)
|
| 150 |
+
|
| 151 |
+
hints_without_deep = build_agent_targeting_hints(
|
| 152 |
+
repo_path=str(repo_path),
|
| 153 |
+
test_identifier=obs.test_identifier,
|
| 154 |
+
failing_stack_trace=obs.failing_stack_trace,
|
| 155 |
+
source_under_test=obs.source_under_test,
|
| 156 |
+
causal_frontier=obs.failure_frontier,
|
| 157 |
+
deep_signals={
|
| 158 |
+
"module_cache_violations": [],
|
| 159 |
+
"fixture_scope_risks": [],
|
| 160 |
+
"mock_residue_sites": [],
|
| 161 |
+
"import_side_effect_files": [],
|
| 162 |
+
},
|
| 163 |
+
max_hints=8,
|
| 164 |
+
)
|
| 165 |
+
|
| 166 |
+
prompt = build_unified_prompt(obs)
|
| 167 |
+
|
| 168 |
+
print("\n=== 1) RAW DEEP DETECTOR OUTPUT ===")
|
| 169 |
+
print(json.dumps(raw_deep, indent=2))
|
| 170 |
+
|
| 171 |
+
print("\n=== 2) OBSERVATION DEEP SIGNALS (post-reset) ===")
|
| 172 |
+
print(json.dumps(obs_deep, indent=2))
|
| 173 |
+
|
| 174 |
+
print("\n=== 3) TARGETING HINTS WITH DEEP SIGNALS ===")
|
| 175 |
+
for i, hint in enumerate(hints_with_deep, 1):
|
| 176 |
+
print(f"{i:02d}. {hint}")
|
| 177 |
+
|
| 178 |
+
print("\n=== 4) TARGETING HINTS WITHOUT DEEP SIGNALS ===")
|
| 179 |
+
for i, hint in enumerate(hints_without_deep, 1):
|
| 180 |
+
print(f"{i:02d}. {hint}")
|
| 181 |
+
|
| 182 |
+
added_by_deep = [h for h in hints_with_deep if h not in hints_without_deep]
|
| 183 |
+
print("\n=== 5) HINTS CONTRIBUTED BY DEEP SIGNALS ===")
|
| 184 |
+
if added_by_deep:
|
| 185 |
+
for i, hint in enumerate(added_by_deep, 1):
|
| 186 |
+
print(f"{i:02d}. {hint}")
|
| 187 |
+
else:
|
| 188 |
+
print("(none in this repo snapshot)")
|
| 189 |
+
|
| 190 |
+
print("\n=== 6) PROMPT SECTION: DEEP FLAKINESS SIGNALS ===")
|
| 191 |
+
print(_extract_section(prompt, "=== DEEP FLAKINESS SIGNALS ==="))
|
| 192 |
+
|
| 193 |
+
print("\n=== 7) PROMPT SECTION: TARGETING HINTS ===")
|
| 194 |
+
print(_extract_section(prompt, "=== TARGETING HINTS ==="))
|
| 195 |
+
|
| 196 |
+
|
| 197 |
+
if __name__ == "__main__":
|
| 198 |
+
main()
|
training-requirements.txt
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
unsloth
|
| 2 |
+
transformers
|
| 3 |
+
datasets
|
| 4 |
+
trl
|
| 5 |
+
accelerate
|
| 6 |
+
bitsandbytes
|
| 7 |
+
wandb
|
| 8 |
+
huggingface_hub
|
| 9 |
+
sentencepiece
|
| 10 |
+
peft
|