vasiuuu commited on
Commit
acf77ab
·
1 Parent(s): 8745930

Initial commit for CodeForge GRPO training

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. Dockerfile +25 -0
  2. app.py +60 -0
  3. codeforge/__init__.py +1 -0
  4. codeforge/app.py +139 -0
  5. codeforge/audit/__init__.py +7 -0
  6. codeforge/audit/ledger.py +39 -0
  7. codeforge/audit/models.py +18 -0
  8. codeforge/audit/reporter.py +53 -0
  9. codeforge/environment.py +515 -0
  10. codeforge/grader.py +39 -0
  11. codeforge/grounder.py +202 -0
  12. codeforge/interrogator/__init__.py +6 -0
  13. codeforge/interrogator/interrogator.py +51 -0
  14. codeforge/interrogator/models.py +9 -0
  15. codeforge/kb/__init__.py +17 -0
  16. codeforge/kb/cluster.py +169 -0
  17. codeforge/kb/code_graph.py +109 -0
  18. codeforge/kb/corpus_manager.py +138 -0
  19. codeforge/kb/indexer.py +168 -0
  20. codeforge/kb/models.py +37 -0
  21. codeforge/kb/skills_corpus.jsonl +0 -0
  22. codeforge/kb/skills_corpus.manifest.json +14 -0
  23. codeforge/kb/tokenizer.py +9 -0
  24. codeforge/mcp_server.py +848 -0
  25. codeforge/models.py +73 -0
  26. codeforge/observation.py +50 -0
  27. codeforge/ralph/__init__.py +17 -0
  28. codeforge/ralph/checkpoint.py +23 -0
  29. codeforge/ralph/loop.py +167 -0
  30. codeforge/ralph/models.py +55 -0
  31. codeforge/ralph/planner.py +90 -0
  32. codeforge/ralph/synthesizer.py +355 -0
  33. codeforge/sandbox/__init__.py +17 -0
  34. codeforge/sandbox/imports.py +67 -0
  35. codeforge/sandbox/metric.py +40 -0
  36. codeforge/sandbox/models.py +41 -0
  37. codeforge/sandbox/runner.py +61 -0
  38. codeforge/sandbox/sandbox.py +100 -0
  39. codeforge/sandbox/tools.py +85 -0
  40. codeforge/scraper/__init__.py +16 -0
  41. codeforge/scraper/chunker.py +108 -0
  42. codeforge/scraper/discovery.py +34 -0
  43. codeforge/scraper/parser.py +32 -0
  44. codeforge/scraper/pipeline.py +154 -0
  45. codeforge/scraper/tagger.py +62 -0
  46. codeforge/scraper/writer.py +52 -0
  47. codeforge/shaping.py +36 -0
  48. codeforge/tasks.py +137 -0
  49. dataset/mbpp.jsonl +0 -0
  50. requirements.txt +11 -0
Dockerfile ADDED
@@ -0,0 +1,25 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM pytorch/pytorch:2.5.1-cuda12.4-cudnn9-devel
2
+
3
+ RUN useradd -m -u 1000 user
4
+ USER user
5
+ ENV PATH="/home/user/.local/bin:$PATH"
6
+
7
+ WORKDIR /app
8
+
9
+ # Upgrade pip and install unsloth properly
10
+ RUN pip install --no-cache-dir --upgrade pip
11
+ RUN pip install "unsloth[colab-new] @ git+https://github.com/unslothai/unsloth.git"
12
+
13
+ COPY --chown=user ./requirements.txt requirements.txt
14
+ RUN pip install --no-cache-dir --upgrade -r requirements.txt
15
+
16
+ # Copy all the code
17
+ COPY --chown=user . /app
18
+
19
+ # Ensure correct permissions for checkpoint saving
20
+ USER root
21
+ RUN chown -R user:user /app
22
+ USER user
23
+
24
+ # Hugging Face spaces expect the app to run on 7860
25
+ CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "7860"]
app.py ADDED
@@ -0,0 +1,60 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from fastapi import FastAPI, BackgroundTasks
2
+ import subprocess
3
+ import threading
4
+ import os
5
+ from fastapi.responses import PlainTextResponse
6
+
7
+ app = FastAPI()
8
+
9
+ # Global state to track training
10
+ training_status = {
11
+ "status": "idle",
12
+ "log": ""
13
+ }
14
+
15
+ def run_training():
16
+ global training_status
17
+ training_status["status"] = "running"
18
+ training_status["log"] = "Started training...\n"
19
+
20
+ # Run the trainer script and capture output
21
+ process = subprocess.Popen(
22
+ ["python", "-m", "trainer.train", "--steps", "300"],
23
+ stdout=subprocess.PIPE,
24
+ stderr=subprocess.STDOUT,
25
+ text=True,
26
+ bufsize=1,
27
+ universal_newlines=True
28
+ )
29
+
30
+ for line in process.stdout:
31
+ training_status["log"] += line
32
+
33
+ process.wait()
34
+ training_status["status"] = f"finished with code {process.returncode}"
35
+ training_status["log"] += f"\nTraining finished with exit code: {process.returncode}\n"
36
+
37
+ @app.get("/")
38
+ def read_root():
39
+ return {
40
+ "message": "CodeForge GRPO Training Node Active",
41
+ "status": training_status["status"],
42
+ "endpoints": {
43
+ "/start": "Start training (runs in background)",
44
+ "/logs": "View live training logs"
45
+ }
46
+ }
47
+
48
+ @app.post("/start")
49
+ def start_training(background_tasks: BackgroundTasks):
50
+ if training_status["status"] == "running":
51
+ return {"message": "Training is already running!"}
52
+
53
+ # Run in a separate thread so it doesn't block the API
54
+ thread = threading.Thread(target=run_training)
55
+ thread.start()
56
+ return {"message": "Training started! Go to /logs to monitor."}
57
+
58
+ @app.get("/logs", response_class=PlainTextResponse)
59
+ def get_logs():
60
+ return training_status["log"]
codeforge/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ from __future__ import annotations
codeforge/app.py ADDED
@@ -0,0 +1,139 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+ import os
5
+
6
+ from dotenv import load_dotenv
7
+
8
+ load_dotenv()
9
+ import shutil
10
+ import threading
11
+ import time
12
+ from pathlib import Path
13
+ from typing import Any
14
+ from uuid import uuid4
15
+
16
+ from fastapi import FastAPI, Response
17
+ from openenv.core.env_server.http_server import create_app
18
+
19
+ from codeforge.environment import CodeForgeEnvironment
20
+ from codeforge.models import CodeForgeAction, CodeForgeObservation
21
+ from codeforge.tasks import TASKS
22
+
23
+ _log = logging.getLogger(__name__)
24
+
25
+ _corpus_path_str = os.environ.get("GROUNDLOOP_CORPUS_PATH")
26
+ _corpus_path = Path(_corpus_path_str) if _corpus_path_str else None
27
+
28
+ # ---------------------------------------------------------------------------
29
+ # Session-keyed environment pool (SYSTEM_DESIGN §15)
30
+ # ---------------------------------------------------------------------------
31
+ _lock = threading.Lock()
32
+ _sessions: dict[str, CodeForgeEnvironment] = {}
33
+ _session_access: dict[str, float] = {} # session_id → last access timestamp
34
+ _MAX_SESSIONS = int(os.environ.get("CODEFORGE_MAX_SESSIONS", "10"))
35
+ _SESSION_TTL_S = int(os.environ.get("CODEFORGE_SESSION_TTL", "3600"))
36
+
37
+
38
+ def _get_or_create_env() -> CodeForgeEnvironment:
39
+ """For OpenEnv compliance — creates a single-session env.
40
+
41
+ The session pool below is used by the MCP server layer.
42
+ """
43
+ return CodeForgeEnvironment(corpus_path=_corpus_path)
44
+
45
+
46
+ def _expire_stale_sessions() -> None:
47
+ """Remove sessions older than TTL. Must hold _lock."""
48
+ now = time.monotonic()
49
+ expired = [
50
+ sid for sid, ts in _session_access.items()
51
+ if now - ts > _SESSION_TTL_S
52
+ ]
53
+ for sid in expired:
54
+ _sessions.pop(sid, None)
55
+ _session_access.pop(sid, None)
56
+
57
+
58
+ def get_session(session_id: str) -> CodeForgeEnvironment | None:
59
+ """Retrieve an existing session by ID. Returns None if expired or missing."""
60
+ with _lock:
61
+ _expire_stale_sessions()
62
+ env = _sessions.get(session_id)
63
+ if env is not None:
64
+ _session_access[session_id] = time.monotonic()
65
+ return env
66
+
67
+
68
+ def create_session() -> tuple[str, CodeForgeEnvironment]:
69
+ """Create a new session. Evicts LRU session if at capacity."""
70
+ sid = uuid4().hex[:16]
71
+ env = CodeForgeEnvironment(corpus_path=_corpus_path)
72
+ now = time.monotonic()
73
+ with _lock:
74
+ _expire_stale_sessions()
75
+ if len(_sessions) >= _MAX_SESSIONS:
76
+ # Evict least-recently-used session
77
+ lru_sid = min(_session_access, key=_session_access.get) # type: ignore[arg-type]
78
+ _sessions.pop(lru_sid, None)
79
+ _session_access.pop(lru_sid, None)
80
+ _sessions[sid] = env
81
+ _session_access[sid] = now
82
+ return sid, env
83
+
84
+
85
+ # ---------------------------------------------------------------------------
86
+ # OpenEnv compliance app
87
+ # ---------------------------------------------------------------------------
88
+ app: FastAPI = create_app(_get_or_create_env, CodeForgeAction, CodeForgeObservation)
89
+
90
+
91
+ @app.get("/", summary="Health check")
92
+ def root() -> dict[str, str]:
93
+ return {"name": "code-forge", "version": "0.2.0", "status": "ok", "docs": "/docs"}
94
+
95
+
96
+ @app.get("/favicon.ico", include_in_schema=False)
97
+ def favicon() -> Response:
98
+ return Response(status_code=204)
99
+
100
+
101
+ @app.get("/tasks", summary="List tasks + action schema")
102
+ def list_tasks() -> dict[str, Any]:
103
+ return {
104
+ "tasks": [
105
+ {
106
+ "id": t.task_id,
107
+ "difficulty": t.task_level,
108
+ "brief": t.brief,
109
+ "target_score": t.target_score,
110
+ "max_budget": t.max_budget,
111
+ "tools": list(t.tools),
112
+ }
113
+ for t in TASKS
114
+ ],
115
+ "action_schema": {
116
+ "action_types": [
117
+ "query_kb",
118
+ "query_cluster",
119
+ "interrogate",
120
+ "run_ralph",
121
+ "submit",
122
+ "get_audit",
123
+ ],
124
+ },
125
+ }
126
+
127
+
128
+ @app.get("/health/deep", summary="Deep health check")
129
+ def health_check() -> dict[str, Any]:
130
+ """Check all dependencies: corpus file, tools."""
131
+ checks: dict[str, bool] = {
132
+ "ruff": shutil.which("ruff") is not None,
133
+ "mypy": shutil.which("mypy") is not None,
134
+ "pytest": shutil.which("pytest") is not None,
135
+ }
136
+ corpus_ok = _corpus_path is not None and Path(_corpus_path).is_file()
137
+ checks["corpus"] = corpus_ok
138
+ all_ok = all(checks.values())
139
+ return {"status": "ok" if all_ok else "degraded", "checks": checks}
codeforge/audit/__init__.py ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from codeforge.audit.ledger import AuditLedger
4
+ from codeforge.audit.models import AuditReport
5
+ from codeforge.audit.reporter import AuditReporter
6
+
7
+ __all__ = ["AuditLedger", "AuditReport", "AuditReporter"]
codeforge/audit/ledger.py ADDED
@@ -0,0 +1,39 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from collections import Counter
4
+ from dataclasses import asdict
5
+
6
+ from codeforge.models import AuditEntry
7
+
8
+
9
+ class AuditLedger:
10
+ """Per-episode, per-step append-only audit log."""
11
+
12
+ def __init__(self) -> None:
13
+ self._entries: list[AuditEntry] = []
14
+
15
+ def append(self, entry: AuditEntry) -> None:
16
+ self._entries.append(entry)
17
+
18
+ def entries(self) -> tuple[AuditEntry, ...]:
19
+ return tuple(self._entries)
20
+
21
+ def total_reward(self) -> float:
22
+ return sum(e.reward for e in self._entries)
23
+
24
+ def citation_count(self) -> dict[str, int]:
25
+ counts: Counter[str] = Counter()
26
+ for entry in self._entries:
27
+ counts.update(entry.cited_skill_ids)
28
+ return dict(counts)
29
+
30
+ def step_count(self) -> int:
31
+ return len(self._entries)
32
+
33
+ def serialize(self) -> dict[str, object]:
34
+ return {
35
+ "entries": [asdict(e) for e in self._entries],
36
+ "total_reward": self.total_reward(),
37
+ "step_count": self.step_count(),
38
+ "citation_count": self.citation_count(),
39
+ }
codeforge/audit/models.py ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from pydantic import BaseModel, ConfigDict
4
+
5
+
6
+ class AuditReport(BaseModel):
7
+ model_config = ConfigDict(frozen=True)
8
+ run_id: str
9
+ summary: str
10
+ iterations_total: int
11
+ iterations_kept: int
12
+ iterations_regressed: int
13
+ iterations_plateau: int
14
+ skill_citations: tuple[tuple[str, int], ...]
15
+ score_trajectory: tuple[float, ...]
16
+ final_score: float
17
+ terminated_by: str
18
+ hallucination_rate: float
codeforge/audit/reporter.py ADDED
@@ -0,0 +1,53 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from collections import Counter
4
+ from typing import TYPE_CHECKING
5
+
6
+ from codeforge.audit.models import AuditReport
7
+
8
+ if TYPE_CHECKING:
9
+ from codeforge.ralph.models import RunResult
10
+
11
+
12
+ class AuditReporter:
13
+ """Builds an AuditReport from a Ralph RunResult."""
14
+
15
+ @staticmethod
16
+ def build(run: RunResult, hallucination_rate: float = 0.0) -> AuditReport:
17
+ cites: Counter[str] = Counter()
18
+ kept = 0
19
+ regressed = 0
20
+ plateau = 0
21
+ trajectory: list[float] = []
22
+
23
+ for it in run.iterations:
24
+ cites.update(it.cited_node_ids)
25
+ trajectory.append(it.sandbox_score_after)
26
+ if it.reason == "score_improved":
27
+ kept += 1
28
+ elif it.reason == "score_regressed":
29
+ regressed += 1
30
+ elif it.reason == "score_plateau":
31
+ plateau += 1
32
+
33
+ summary = (
34
+ f"run={run.run_id} iters={len(run.iterations)} "
35
+ f"final={run.final_score:.3f} terminated_by={run.terminated_by}"
36
+ )
37
+ skill_citations = tuple(
38
+ sorted(cites.items(), key=lambda kv: (-kv[1], kv[0]))
39
+ )
40
+
41
+ return AuditReport(
42
+ run_id=run.run_id,
43
+ summary=summary,
44
+ iterations_total=len(run.iterations),
45
+ iterations_kept=kept,
46
+ iterations_regressed=regressed,
47
+ iterations_plateau=plateau,
48
+ skill_citations=skill_citations,
49
+ score_trajectory=tuple(trajectory),
50
+ final_score=run.final_score,
51
+ terminated_by=run.terminated_by,
52
+ hallucination_rate=hallucination_rate,
53
+ )
codeforge/environment.py ADDED
@@ -0,0 +1,515 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+ import re
5
+ import uuid
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ from openenv.core.env_server.interfaces import Environment
10
+
11
+ from codeforge.audit.ledger import AuditLedger
12
+ from codeforge.grader import compute_reward
13
+ from codeforge.grounder import ground
14
+ from codeforge.interrogator.interrogator import Interrogator
15
+ from codeforge.kb.cluster import build_clusters
16
+ from codeforge.kb.indexer import SkillsIndex
17
+ from codeforge.models import AuditEntry, CodeForgeAction, CodeForgeActionType, CodeForgeObservation
18
+ from codeforge.observation import build_observation
19
+ from codeforge.ralph.loop import run_loop
20
+ from codeforge.ralph.models import LoopConfig
21
+ from codeforge.ralph.synthesizer import StubSynthesizer, Synthesizer
22
+ from codeforge.sandbox.sandbox import run_sandbox
23
+ from codeforge.shaping import citation_shaping_bonus
24
+ from codeforge.tasks import Task, get_task
25
+
26
+ _log = logging.getLogger(__name__)
27
+ _DEFAULT_CORPUS = Path(__file__).resolve().parent / "kb" / "skills_corpus.jsonl"
28
+
29
+ # ---------------------------------------------------------------------------
30
+ # Filename validation (SYSTEM_DESIGN §14.2, §14.3)
31
+ # ---------------------------------------------------------------------------
32
+ _FILENAME_RE = re.compile(r"^[a-z][a-z0-9_]*\.py$")
33
+ _FORBIDDEN_FILENAMES = frozenset({
34
+ "conftest.py", "pytest.ini", "setup.cfg", "pyproject.toml", "tox.ini",
35
+ })
36
+ _MAX_FILES = 10
37
+ _MAX_FILE_SIZE = 50 * 1024 # 50 KB
38
+ _MAX_TOTAL_SIZE = 200 * 1024 # 200 KB
39
+
40
+
41
+ def _validate_files(files: dict[str, str]) -> str | None:
42
+ """Return an error message if *files* violates submission constraints, else None."""
43
+ if not files:
44
+ return "files dict is empty"
45
+ if len(files) > _MAX_FILES:
46
+ return f"too many files ({len(files)} > {_MAX_FILES})"
47
+ total_size = 0
48
+ for name, content in files.items():
49
+ if name in _FORBIDDEN_FILENAMES:
50
+ return f"filename '{name}' is not allowed"
51
+ if not _FILENAME_RE.match(name):
52
+ return f"filename '{name}' must match [a-z][a-z0-9_]*.py"
53
+ size = len(content.encode("utf-8"))
54
+ if size > _MAX_FILE_SIZE:
55
+ return f"file '{name}' exceeds {_MAX_FILE_SIZE} bytes"
56
+ total_size += size
57
+ if total_size > _MAX_TOTAL_SIZE:
58
+ return f"total size ({total_size}) exceeds {_MAX_TOTAL_SIZE} bytes"
59
+ return None
60
+
61
+
62
+ # ---------------------------------------------------------------------------
63
+ # Valid action types (for fast membership check)
64
+ # ---------------------------------------------------------------------------
65
+ _VALID_ACTION_TYPES = frozenset(member.value for member in CodeForgeActionType)
66
+
67
+
68
+ # ---------------------------------------------------------------------------
69
+ # Environment
70
+ # ---------------------------------------------------------------------------
71
+
72
+
73
+ class CodeForgeEnvironment(Environment): # type: ignore[type-arg]
74
+ """OpenEnv-compliant RL environment with all 6 CodeForge actions.
75
+
76
+ Implements SYSTEM_DESIGN §4.9, §5.2, §17.
77
+ """
78
+
79
+ SUPPORTS_CONCURRENT_SESSIONS = True
80
+
81
+ def __init__(
82
+ self,
83
+ *,
84
+ corpus_path: Path | None = None,
85
+ synthesizer: Synthesizer | None = None,
86
+ ) -> None:
87
+ super().__init__()
88
+ self._corpus_path = corpus_path or _DEFAULT_CORPUS
89
+ self._synthesizer = synthesizer
90
+ self._index: SkillsIndex | None = None
91
+ self._task: Task | None = None
92
+ self._episode_id: str = ""
93
+ self._budget_remaining: int = 0
94
+ self._current_files: dict[str, str] = {}
95
+ self._previous_score: float = 0.0
96
+ self._is_done: bool = False
97
+
98
+ # Per-step state
99
+ self._last_citations: tuple[dict[str, object], ...] = ()
100
+ self._last_grounding: dict[str, object] | None = None
101
+ self._last_reward: float = 0.0
102
+ self._last_cluster_hits: tuple[str, ...] = ()
103
+ self._last_interrogation_questions: tuple[str, ...] = ()
104
+ self._last_ralph_run_id: str | None = None
105
+ self._last_ralph_iterations: tuple[dict[str, object], ...] = ()
106
+
107
+ # Brier/quality tracking for audit entries
108
+ self._last_brier_penalty: float | None = None
109
+ self._last_quality: float = 0.0
110
+
111
+ # Episode-level accumulators
112
+ self._all_episode_citations: list[dict[str, object]] = []
113
+ self._all_episode_cluster_hits: list[str] = []
114
+ self._ledger: AuditLedger | None = None
115
+ self._step_index: int = 0
116
+
117
+ # ------------------------------------------------------------------
118
+ # Index management
119
+ # ------------------------------------------------------------------
120
+
121
+ def _ensure_index(self) -> SkillsIndex:
122
+ if self._index is None:
123
+ if not self._corpus_path.is_file():
124
+ msg = (
125
+ f"corpus not found: {self._corpus_path}. "
126
+ f"Run the skills scraper first."
127
+ )
128
+ raise FileNotFoundError(msg)
129
+ idx = SkillsIndex(corpus_path=self._corpus_path)
130
+ idx.build()
131
+ # Build and attach clusters
132
+ import json
133
+ nodes: list[dict[str, Any]] = []
134
+ with self._corpus_path.open(encoding="utf-8") as f:
135
+ for line in f:
136
+ line = line.strip()
137
+ if line:
138
+ nodes.append(json.loads(line))
139
+ manifest = build_clusters(nodes)
140
+ idx.attach_cluster_manifest(manifest)
141
+ self._index = idx
142
+ return self._index
143
+
144
+ # ------------------------------------------------------------------
145
+ # OpenEnv interface
146
+ # ------------------------------------------------------------------
147
+
148
+ def reset(
149
+ self,
150
+ seed: int | None = None,
151
+ episode_id: str | None = None,
152
+ **kwargs: Any,
153
+ ) -> CodeForgeObservation:
154
+ task_level: str = kwargs.get("task_level", "easy")
155
+ task = get_task(task_level)
156
+ self._task = task
157
+ self._episode_id = episode_id or uuid.uuid4().hex[:12]
158
+ self._budget_remaining = task.max_budget
159
+ self._current_files = dict(task.initial_files)
160
+ self._previous_score = 0.0
161
+ self._is_done = False
162
+
163
+ # Reset per-step
164
+ self._last_citations = ()
165
+ self._last_grounding = None
166
+ self._last_reward = 0.0
167
+ self._last_cluster_hits = ()
168
+ self._last_interrogation_questions = ()
169
+ self._last_ralph_run_id = None
170
+ self._last_ralph_iterations = ()
171
+
172
+ # Reset episode accumulators
173
+ self._all_episode_citations = []
174
+ self._all_episode_cluster_hits = []
175
+ self._ledger = AuditLedger()
176
+ self._step_index = 0
177
+
178
+ _log.info(
179
+ "reset id=%s task=%s budget=%s",
180
+ self._episode_id, task.task_id, task.max_budget,
181
+ )
182
+ return self._build_obs()
183
+
184
+ def step(
185
+ self,
186
+ action: CodeForgeAction,
187
+ timeout_s: float | None = None,
188
+ **kwargs: Any,
189
+ ) -> CodeForgeObservation:
190
+ # --- Pre-check: no active episode --------------------------------
191
+ if self._task is None:
192
+ return self._error_obs("No active episode — call reset() first")
193
+
194
+ # --- Pre-check: episode already done -----------------------------
195
+ if self._is_done:
196
+ return self._build_obs()
197
+
198
+ # --- Pre-check: valid action_type --------------------------------
199
+ action_type_str = str(action.action_type)
200
+ if action_type_str not in _VALID_ACTION_TYPES:
201
+ return self._error_obs(f"Unknown action_type: {action_type_str!r}")
202
+
203
+ # --- Budget check (variable cost) --------------------------------
204
+ cost = self._action_cost(action)
205
+ if cost > self._budget_remaining:
206
+ return self._error_obs(
207
+ f"Insufficient budget: need {cost}, have {self._budget_remaining}"
208
+ )
209
+ self._budget_remaining -= cost
210
+
211
+ # --- Clear per-step state ----------------------------------------
212
+ self._last_reward = 0.0
213
+ self._last_citations = ()
214
+ self._last_grounding = None
215
+ self._last_cluster_hits = ()
216
+ self._last_interrogation_questions = ()
217
+ self._last_ralph_run_id = None
218
+ self._last_ralph_iterations = ()
219
+ error: str | None = None
220
+
221
+ # --- Route to handler --------------------------------------------
222
+ try:
223
+ if action_type_str == CodeForgeActionType.QUERY_KB:
224
+ error = self._handle_query_kb(action)
225
+ elif action_type_str == CodeForgeActionType.QUERY_CLUSTER:
226
+ error = self._handle_query_cluster(action)
227
+ elif action_type_str == CodeForgeActionType.INTERROGATE:
228
+ error = self._handle_interrogate(action)
229
+ elif action_type_str == CodeForgeActionType.SUBMIT:
230
+ error = self._handle_submit(action)
231
+ elif action_type_str == CodeForgeActionType.RUN_RALPH:
232
+ error = self._handle_run_ralph(action)
233
+ elif action_type_str == CodeForgeActionType.GET_AUDIT:
234
+ error = self._handle_get_audit(action)
235
+ except Exception as exc:
236
+ _log.exception("handler error: %s", exc)
237
+ error = f"Internal error: {exc}"
238
+
239
+ # --- Append audit entry ------------------------------------------
240
+ assert self._ledger is not None
241
+ _cited: list[str] = []
242
+ _cite: dict[str, object]
243
+ for _cite in self._last_citations:
244
+ _cited.append(str(_cite.get("node_id", "")))
245
+ cited_ids: tuple[str, ...] = tuple(_cited)
246
+ self._ledger.append(
247
+ AuditEntry(
248
+ step_index=self._step_index,
249
+ action_type=action_type_str,
250
+ cited_skill_ids=cited_ids,
251
+ cited_clusters=self._last_cluster_hits,
252
+ grounding_report=(
253
+ self._last_grounding if self._last_grounding else None
254
+ ),
255
+ reward=self._last_reward,
256
+ brier_penalty=(
257
+ self._last_brier_penalty
258
+ if action_type_str == CodeForgeActionType.SUBMIT
259
+ else None
260
+ ),
261
+ confidence_declared=(
262
+ action.confidence
263
+ if action_type_str == CodeForgeActionType.SUBMIT
264
+ else None
265
+ ),
266
+ quality=(
267
+ self._last_quality
268
+ if action_type_str == CodeForgeActionType.SUBMIT
269
+ else self._previous_score
270
+ ),
271
+ ),
272
+ )
273
+ self._step_index += 1
274
+
275
+ # --- Check budget exhaustion -------------------------------------
276
+ if self._budget_remaining <= 0:
277
+ self._is_done = True
278
+
279
+ return self._build_obs(error=error)
280
+
281
+ @property
282
+ def state(self) -> CodeForgeObservation:
283
+ if self._task is None:
284
+ return self._error_obs("No active episode — call reset() first")
285
+ return self._build_obs()
286
+
287
+ # ------------------------------------------------------------------
288
+ # Cost computation
289
+ # ------------------------------------------------------------------
290
+
291
+ @staticmethod
292
+ def _action_cost(action: CodeForgeAction) -> int:
293
+ """Variable-cost budget accounting (SYSTEM_DESIGN §17.2)."""
294
+ if str(action.action_type) == CodeForgeActionType.GET_AUDIT:
295
+ return 0
296
+ if str(action.action_type) == CodeForgeActionType.RUN_RALPH:
297
+ return action.max_iters
298
+ return 1
299
+
300
+ # ------------------------------------------------------------------
301
+ # Action handlers (each returns an error string or None)
302
+ # ------------------------------------------------------------------
303
+
304
+ def _handle_query_kb(self, action: CodeForgeAction) -> str | None:
305
+ try:
306
+ idx = self._ensure_index()
307
+ except FileNotFoundError as e:
308
+ _log.warning("query_kb: no corpus: %s", e)
309
+ self._last_citations = ()
310
+ return None
311
+ tags = set(action.required_tags) if action.required_tags else None
312
+ results = idx.search(
313
+ action.claim or "", top_k=action.top_k, required_tags=tags,
314
+ )
315
+ self._last_citations = tuple(
316
+ {
317
+ "node_id": r.node_id,
318
+ "skill_name": r.skill_name,
319
+ "section_path": list(r.section_path),
320
+ "section_body": r.section_body,
321
+ "score": r.score,
322
+ "rank": r.rank,
323
+ }
324
+ for r in results
325
+ )
326
+ self._all_episode_citations.extend(self._last_citations)
327
+ return None
328
+
329
+ def _handle_query_cluster(self, action: CodeForgeAction) -> str | None:
330
+ try:
331
+ idx = self._ensure_index()
332
+ except FileNotFoundError as e:
333
+ _log.warning("query_cluster: no corpus: %s", e)
334
+ self._last_cluster_hits = ()
335
+ return None
336
+ label = action.cluster_label or ""
337
+ results = idx.nodes_in_cluster(label)
338
+ if not results:
339
+ self._last_cluster_hits = ()
340
+ return None
341
+ self._last_cluster_hits = tuple(r.node_id for r in results)
342
+ self._all_episode_cluster_hits.extend(self._last_cluster_hits)
343
+ return None
344
+
345
+ def _handle_interrogate(self, action: CodeForgeAction) -> str | None:
346
+ idx: SkillsIndex | None
347
+ try:
348
+ idx = self._ensure_index()
349
+ except FileNotFoundError:
350
+ idx = None
351
+ interrogator = Interrogator(idx)
352
+ assert self._task is not None
353
+ result = interrogator.generate(self._task.brief)
354
+ self._last_interrogation_questions = result.questions
355
+ return None
356
+
357
+ def _handle_submit(self, action: CodeForgeAction) -> str | None:
358
+ if action.files is None:
359
+ return "files required for submit"
360
+ file_err = _validate_files(action.files)
361
+ if file_err is not None:
362
+ return file_err
363
+
364
+ self._current_files = dict(action.files)
365
+ assert self._task is not None
366
+
367
+ # Merge hidden correctness tests into sandbox files (agent cannot see these)
368
+ sandbox_files = dict(action.files)
369
+ if self._task.hidden_tests:
370
+ sandbox_files.update(self._task.hidden_tests)
371
+
372
+ # Run sandbox
373
+ try:
374
+ sandbox_result = run_sandbox(
375
+ files=sandbox_files,
376
+ tools=self._task.tools,
377
+ timeout_per_tool=30.0,
378
+ )
379
+ sandbox_score = sandbox_result.composite_score
380
+ except Exception as e:
381
+ _log.exception("sandbox error: %s", e)
382
+ sandbox_score = 0.0
383
+
384
+ # Run grounder (pass local module names so they're not penalized)
385
+ local_modules = frozenset(
386
+ f.removesuffix(".py") for f in action.files if f.endswith(".py")
387
+ )
388
+ concatenated = "\n".join(action.files.values())
389
+ grounding_report = ground(concatenated, local_modules=local_modules)
390
+ self._last_grounding = grounding_report.model_dump()
391
+
392
+ # Compute reward with Brier calibration
393
+ quality = 0.6 * sandbox_score + 0.4 * grounding_report.groundedness
394
+ effective_conf = action.confidence if action.confidence is not None else 0.5
395
+ brier_penalty: float | None = min((effective_conf - quality) ** 2, 0.5)
396
+ self._last_brier_penalty = brier_penalty
397
+ self._last_quality = quality
398
+
399
+ reward = compute_reward(
400
+ sandbox_score=sandbox_score,
401
+ groundedness=grounding_report.groundedness,
402
+ confidence=action.confidence,
403
+ )
404
+
405
+ # Apply citation shaping bonus only on successful submits (§4.8.4)
406
+ if reward > 0:
407
+ shaping = citation_shaping_bonus(
408
+ submit_files=action.files,
409
+ prior_citations=self._all_episode_citations,
410
+ prior_cluster_hits=self._all_episode_cluster_hits,
411
+ )
412
+ reward = round(min(1.0, reward + shaping), 3)
413
+
414
+ self._last_reward = reward
415
+ self._previous_score = reward
416
+
417
+ # Check target score
418
+ if reward >= self._task.target_score:
419
+ self._is_done = True
420
+
421
+ return None
422
+
423
+ def _handle_run_ralph(self, action: CodeForgeAction) -> str | None:
424
+ assert self._task is not None
425
+ try:
426
+ idx = self._ensure_index()
427
+ except FileNotFoundError as e:
428
+ return f"corpus not available: {e}"
429
+
430
+ config = LoopConfig(
431
+ max_iters=action.max_iters,
432
+ target_score=self._task.target_score,
433
+ tools=self._task.tools,
434
+ )
435
+ synthesizer = self._synthesizer or StubSynthesizer()
436
+ result = run_loop(
437
+ spec=self._task.brief,
438
+ initial_files=self._current_files,
439
+ index=idx,
440
+ synthesizer=synthesizer,
441
+ config=config,
442
+ )
443
+
444
+ self._last_ralph_run_id = result.run_id
445
+ self._last_ralph_iterations = tuple(
446
+ it.model_dump() for it in result.iterations
447
+ )
448
+ self._current_files = dict(result.final_files)
449
+
450
+ # Compute ralph reward (SYSTEM_DESIGN §4.8.5)
451
+ concatenated = "\n".join(result.final_files.values())
452
+ grounding_report = ground(concatenated)
453
+ self._last_grounding = grounding_report.model_dump()
454
+
455
+ wasted = sum(
456
+ 1 for it in result.iterations if it.reason in ("score_regressed", "score_plateau")
457
+ )
458
+ base = compute_reward(
459
+ sandbox_score=result.final_score,
460
+ groundedness=grounding_report.groundedness,
461
+ confidence=0.75,
462
+ )
463
+ waste_penalty = wasted * 0.05
464
+ ralph_reward = round(max(0.0, min(1.0, base - waste_penalty)), 3)
465
+
466
+ self._last_reward = ralph_reward
467
+ self._previous_score = ralph_reward
468
+ return None
469
+
470
+ def _handle_get_audit(self, action: CodeForgeAction) -> str | None:
471
+ # Audit data is populated in _build_obs via cumulative_audit_summary
472
+ return None
473
+
474
+ # ------------------------------------------------------------------
475
+ # Observation helpers
476
+ # ------------------------------------------------------------------
477
+
478
+ def _build_obs(self, *, error: str | None = None) -> CodeForgeObservation:
479
+ assert self._task is not None
480
+ audit_summary: dict[str, object] | None = None
481
+ if self._ledger is not None:
482
+ audit_summary = self._ledger.serialize()
483
+ return build_observation(
484
+ episode_id=self._episode_id,
485
+ task=self._task,
486
+ current_files=self._current_files,
487
+ budget_remaining=self._budget_remaining,
488
+ previous_score=self._previous_score,
489
+ last_citations=self._last_citations,
490
+ last_grounding=self._last_grounding,
491
+ is_done=self._is_done,
492
+ last_reward=self._last_reward,
493
+ last_cluster_hits=self._last_cluster_hits,
494
+ last_interrogation_questions=self._last_interrogation_questions,
495
+ last_ralph_run_id=self._last_ralph_run_id,
496
+ last_ralph_iterations=self._last_ralph_iterations,
497
+ cumulative_audit_summary=audit_summary,
498
+ error=error,
499
+ )
500
+
501
+ def _error_obs(self, msg: str) -> CodeForgeObservation:
502
+ """Return an error observation without modifying episode state."""
503
+ if self._task is None:
504
+ # No task set — use a dummy task for the observation structure
505
+ dummy = get_task("easy")
506
+ return build_observation(
507
+ episode_id=self._episode_id or "none",
508
+ task=dummy,
509
+ current_files=self._current_files,
510
+ budget_remaining=self._budget_remaining,
511
+ previous_score=self._previous_score,
512
+ is_done=self._is_done,
513
+ error=msg,
514
+ )
515
+ return self._build_obs(error=msg)
codeforge/grader.py ADDED
@@ -0,0 +1,39 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ _SANDBOX_WEIGHT = 0.6
4
+ _GROUNDING_WEIGHT = 0.4
5
+ _BRIER_CAP = 0.5
6
+ _UNCERTAIN_CONFIDENCE_THRESHOLD = 0.3
7
+ _UNCERTAIN_QUALITY_THRESHOLD = 0.5
8
+ _UNCERTAIN_FLOOR = 0.50
9
+
10
+
11
+ def compute_reward(
12
+ *,
13
+ sandbox_score: float,
14
+ groundedness: float,
15
+ confidence: float | None = None,
16
+ ) -> float:
17
+ """Compute the final reward for a submit action.
18
+
19
+ quality = weighted combination of sandbox and grounding signals
20
+ brier = calibration penalty (overconfidence on bad code is punished)
21
+ uncertain = floor reward for honest uncertainty (below all task targets)
22
+ """
23
+ quality = _SANDBOX_WEIGHT * sandbox_score + _GROUNDING_WEIGHT * groundedness
24
+
25
+ # Brier calibration: confidence=None treated as 0.5 (mediocre calibration)
26
+ # so agents cannot bypass Brier entirely by omitting confidence.
27
+ effective_confidence = confidence if confidence is not None else 0.5
28
+ brier_penalty = min((effective_confidence - quality) ** 2, _BRIER_CAP)
29
+
30
+ reward = quality * (1.0 - brier_penalty)
31
+
32
+ if (
33
+ confidence is not None
34
+ and confidence < _UNCERTAIN_CONFIDENCE_THRESHOLD
35
+ and quality < _UNCERTAIN_QUALITY_THRESHOLD
36
+ ):
37
+ reward = max(reward, _UNCERTAIN_FLOOR)
38
+
39
+ return round(max(0.0, min(1.0, reward)), 3)
codeforge/grounder.py ADDED
@@ -0,0 +1,202 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import ast
4
+ import importlib
5
+ import importlib.util
6
+ import logging
7
+ from typing import Literal
8
+
9
+ from pydantic import BaseModel, ConfigDict
10
+
11
+ _log = logging.getLogger(__name__)
12
+
13
+
14
+ # ---------------------------------------------------------------------------
15
+ # Models
16
+ # ---------------------------------------------------------------------------
17
+
18
+
19
+ class Symbol(BaseModel):
20
+ """A single symbol extracted from source code by AST walking."""
21
+
22
+ model_config = ConfigDict(frozen=True)
23
+ module: str
24
+ attr: str | None
25
+ kind: Literal["import", "attribute"]
26
+ resolved: bool
27
+ line: int
28
+
29
+
30
+ class GroundingReport(BaseModel):
31
+ """Result of grounding analysis on source code."""
32
+
33
+ model_config = ConfigDict(frozen=True)
34
+ total_symbols: int
35
+ grounded: tuple[Symbol, ...]
36
+ ungrounded: tuple[Symbol, ...]
37
+ groundedness: float
38
+
39
+
40
+ # ---------------------------------------------------------------------------
41
+ # Helpers
42
+ # ---------------------------------------------------------------------------
43
+
44
+
45
+ def _module_spec(name: str) -> bool:
46
+ """Return True if the module can be found by the import system."""
47
+ try:
48
+ return importlib.util.find_spec(name) is not None
49
+ except (ImportError, ValueError, ModuleNotFoundError):
50
+ return False
51
+
52
+
53
+ def _has_attr(module_name: str, attr: str) -> bool:
54
+ """Check if *module_name* exposes *attr*.
55
+
56
+ Uses the FULL module path (e.g. ``os.path``) — not just
57
+ the top-level package. This is the fix for SYSTEM_DESIGN §4.8.3
58
+ bug #3.
59
+ """
60
+ try:
61
+ mod = importlib.import_module(module_name)
62
+ except Exception:
63
+ return False
64
+ return hasattr(mod, attr)
65
+
66
+
67
+ # ---------------------------------------------------------------------------
68
+ # Public API
69
+ # ---------------------------------------------------------------------------
70
+
71
+
72
+ def ground(
73
+ source: str,
74
+ *,
75
+ local_modules: frozenset[str] = frozenset(),
76
+ ) -> GroundingReport:
77
+ """AST-parse *source*, check every import and attribute access resolves.
78
+
79
+ Three fixes baked in from day one (SYSTEM_DESIGN §4.8.3):
80
+ 1. SyntaxError → groundedness=0.0 (was 1.0)
81
+ 2. Zero symbols → groundedness=0.5 (was 1.0)
82
+ 3. Attribute resolution against full module path (was top-level only)
83
+
84
+ *local_modules*: set of module names (e.g. ``{"core", "main"}``) that are
85
+ local to the agent's project and should be treated as grounded even though
86
+ ``importlib.util.find_spec`` cannot resolve them from the grader process.
87
+ """
88
+ # ----- parse --------------------------------------------------------
89
+ try:
90
+ tree = ast.parse(source)
91
+ except SyntaxError:
92
+ # FIX 1: unparseable code → 0.0, not 1.0
93
+ return GroundingReport(
94
+ total_symbols=0,
95
+ grounded=(),
96
+ ungrounded=(),
97
+ groundedness=0.0,
98
+ )
99
+
100
+ symbols: list[Symbol] = []
101
+ import_to_module: dict[str, str] = {}
102
+
103
+ # ----- walk imports -------------------------------------------------
104
+ for node in ast.walk(tree):
105
+ if isinstance(node, ast.Import):
106
+ for alias in node.names:
107
+ pkg = alias.name.split(".")[0]
108
+ # Local modules are always treated as grounded
109
+ resolved = (
110
+ pkg in local_modules or _module_spec(alias.name)
111
+ )
112
+ symbols.append(
113
+ Symbol(
114
+ module=alias.name,
115
+ attr=None,
116
+ kind="import",
117
+ resolved=resolved,
118
+ line=node.lineno,
119
+ )
120
+ )
121
+ import_to_module[alias.asname or pkg] = alias.name
122
+
123
+ elif isinstance(node, ast.ImportFrom):
124
+ if node.level != 0 or node.module is None:
125
+ continue
126
+ mod_top = node.module.split(".")[0]
127
+ is_local = mod_top in local_modules
128
+ resolved_mod = is_local or _module_spec(node.module)
129
+ for alias in (node.names or []):
130
+ attr_resolved = resolved_mod if is_local else (
131
+ resolved_mod and _has_attr(node.module, alias.name)
132
+ )
133
+ symbols.append(
134
+ Symbol(
135
+ module=node.module,
136
+ attr=alias.name,
137
+ kind="import",
138
+ resolved=attr_resolved,
139
+ line=node.lineno,
140
+ )
141
+ )
142
+
143
+ # ----- walk attribute accesses --------------------------------------
144
+ for node in ast.walk(tree):
145
+ if not isinstance(node, ast.Attribute):
146
+ continue
147
+
148
+ # Resolve the chain: e.g. os.path.join → base="os", chain=["path"], attr="join"
149
+ chain: list[str] = []
150
+ cursor: ast.expr = node.value
151
+ while isinstance(cursor, ast.Attribute):
152
+ chain.append(cursor.attr)
153
+ cursor = cursor.value
154
+ if not isinstance(cursor, ast.Name):
155
+ continue
156
+
157
+ base = cursor.id
158
+ mod_name = import_to_module.get(base)
159
+ if mod_name is None:
160
+ continue
161
+
162
+ # Build the full module path for chained access:
163
+ # import os.path → import_to_module["os"] = "os.path"
164
+ # os.path.join → chain=["path"], we need to resolve "join" against "os.path"
165
+ # The chain intermediates are sub-module parts already covered by mod_name.
166
+ # We check the final attr against the deepest resolvable module.
167
+ if chain:
168
+ # chain was built bottom-up, reverse to get top-down order
169
+ chain.reverse()
170
+ # Build candidate module: mod_name + chain parts
171
+ full_mod = mod_name + "." + ".".join(chain)
172
+ # Try the full module first; fall back to mod_name if it doesn't exist
173
+ check_mod = full_mod if _module_spec(full_mod) else mod_name
174
+ else:
175
+ check_mod = mod_name
176
+
177
+ # FIX 3: resolve against full module path, not just top-level
178
+ resolved = _has_attr(check_mod, node.attr)
179
+ symbols.append(
180
+ Symbol(
181
+ module=check_mod,
182
+ attr=node.attr,
183
+ kind="attribute",
184
+ resolved=resolved,
185
+ line=node.lineno,
186
+ )
187
+ )
188
+
189
+ # ----- compute groundedness -----------------------------------------
190
+ grounded = tuple(s for s in symbols if s.resolved)
191
+ ungrounded = tuple(s for s in symbols if not s.resolved)
192
+ total = len(symbols)
193
+
194
+ # FIX 2: zero symbols → 0.5 (neutral), not 1.0
195
+ groundedness = 0.5 if total == 0 else len(grounded) / total
196
+
197
+ return GroundingReport(
198
+ total_symbols=total,
199
+ grounded=grounded,
200
+ ungrounded=ungrounded,
201
+ groundedness=groundedness,
202
+ )
codeforge/interrogator/__init__.py ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from codeforge.interrogator.interrogator import Interrogator
4
+ from codeforge.interrogator.models import InterrogationResult
5
+
6
+ __all__ = ["InterrogationResult", "Interrogator"]
codeforge/interrogator/interrogator.py ADDED
@@ -0,0 +1,51 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from typing import TYPE_CHECKING
4
+
5
+ from codeforge.interrogator.models import InterrogationResult
6
+
7
+ if TYPE_CHECKING:
8
+ from codeforge.kb.indexer import SkillsIndex
9
+
10
+ _TEMPLATES = (
11
+ "What is the exact success criterion for '{brief_head}'?",
12
+ "Have you considered the guidance from {skill_name}: '{section_title}'?",
13
+ "Which of these assumptions is most load-bearing: success metric, inputs, failure modes?",
14
+ "What is the single hardest edge case for '{brief_head}'?",
15
+ "Have you consulted {skill_name2} for the patterns it recommends?",
16
+ )
17
+
18
+
19
+ class Interrogator:
20
+ """Generates Socratic questions that cite real skill corpus nodes."""
21
+
22
+ def __init__(self, index: SkillsIndex | None) -> None:
23
+ self._index = index
24
+
25
+ def generate(self, brief: str, *, top_k: int = 5) -> InterrogationResult:
26
+ brief_head = brief.strip()[:80] or "the task"
27
+ results = (
28
+ self._index.search(brief, top_k=top_k)
29
+ if self._index is not None
30
+ else []
31
+ )
32
+ cited_ids = tuple(r.node_id for r in results[:2])
33
+ first = results[0] if results else None
34
+ second = results[1] if len(results) > 1 else first
35
+
36
+ skill_name = first.skill_name if first else "the skill library"
37
+ section_title = (
38
+ "/".join(first.section_path) if first else "the relevant section"
39
+ )
40
+ skill_name2 = second.skill_name if second else skill_name
41
+
42
+ questions = tuple(
43
+ t.format(
44
+ brief_head=brief_head,
45
+ skill_name=skill_name,
46
+ section_title=section_title,
47
+ skill_name2=skill_name2,
48
+ )
49
+ for t in _TEMPLATES
50
+ )
51
+ return InterrogationResult(questions=questions, cited_node_ids=cited_ids)
codeforge/interrogator/models.py ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from pydantic import BaseModel, ConfigDict
4
+
5
+
6
+ class InterrogationResult(BaseModel):
7
+ model_config = ConfigDict(frozen=True)
8
+ questions: tuple[str, ...]
9
+ cited_node_ids: tuple[str, ...]
codeforge/kb/__init__.py ADDED
@@ -0,0 +1,17 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from codeforge.kb.cluster import build_clusters
4
+ from codeforge.kb.indexer import SkillsIndex
5
+ from codeforge.kb.models import (
6
+ Cluster,
7
+ ClusterManifest,
8
+ SearchResult,
9
+ )
10
+
11
+ __all__ = [
12
+ "Cluster",
13
+ "ClusterManifest",
14
+ "SearchResult",
15
+ "SkillsIndex",
16
+ "build_clusters",
17
+ ]
codeforge/kb/cluster.py ADDED
@@ -0,0 +1,169 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import hashlib
4
+ from collections import Counter
5
+ from collections.abc import Iterable
6
+ from datetime import UTC, datetime
7
+ from typing import Any
8
+
9
+ from codeforge.kb.models import Cluster, ClusterManifest
10
+ from codeforge.kb.tokenizer import tokenize
11
+
12
+ _STOPWORDS: frozenset[str] = frozenset({
13
+ "the", "and", "for", "with", "this", "that", "are", "was",
14
+ "not", "but", "use", "can", "all", "one", "from", "when",
15
+ "which", "have", "any", "should", "would", "must", "will",
16
+ "your", "you", "our", "its", "their", "them", "they",
17
+ })
18
+
19
+
20
+ def _filter_tokens(tokens: list[str], min_length: int = 3) -> list[str]:
21
+ return [t for t in tokens if len(t) >= min_length and t not in _STOPWORDS]
22
+
23
+
24
+ def _jaccard(a: set[str], b: set[str]) -> float:
25
+ if not a and not b:
26
+ return 0.0
27
+ union = a | b
28
+ inter = a & b
29
+ return len(inter) / len(union)
30
+
31
+
32
+ def _connected_components(
33
+ node_ids: list[str], adj: dict[str, set[str]],
34
+ ) -> list[set[str]]:
35
+ seen: set[str] = set()
36
+ comps: list[set[str]] = []
37
+ for start in node_ids:
38
+ if start in seen:
39
+ continue
40
+ comp: set[str] = set()
41
+ stack = [start]
42
+ while stack:
43
+ nid = stack.pop()
44
+ if nid in comp:
45
+ continue
46
+ comp.add(nid)
47
+ seen.add(nid)
48
+ for nbr in adj.get(nid, ()):
49
+ if nbr not in comp:
50
+ stack.append(nbr)
51
+ comps.append(comp)
52
+ return comps
53
+
54
+
55
+ def _dominant_domain(tags_list: Iterable[tuple[str, ...]]) -> str:
56
+ counts: Counter[str] = Counter()
57
+ for tags in tags_list:
58
+ for t in tags:
59
+ if t.startswith("domain:"):
60
+ counts[t.split(":", 1)[1]] += 1
61
+ if not counts:
62
+ return "general"
63
+ return counts.most_common(1)[0][0]
64
+
65
+
66
+ def _label_cluster(
67
+ nodes_data: list[dict[str, Any]],
68
+ ) -> tuple[str, tuple[str, ...], str]:
69
+ all_tokens: Counter[str] = Counter()
70
+ tag_lists: list[tuple[str, ...]] = []
71
+ for nd in nodes_data:
72
+ all_tokens.update(nd["tokens"])
73
+ tag_lists.append(tuple(nd.get("tags", ())))
74
+ top3 = [t for t, _ in all_tokens.most_common(3)]
75
+ dominant = _dominant_domain(tag_lists)
76
+ label = f"{dominant}_" + "_".join(top3) if top3 else dominant
77
+ return label, tuple(top3), dominant
78
+
79
+
80
+ def build_clusters(
81
+ nodes: list[dict[str, Any]],
82
+ *,
83
+ jaccard_threshold: float = 0.15,
84
+ min_token_length: int = 3,
85
+ corpus_sha256: str = "",
86
+ generated_at: str | None = None,
87
+ ) -> ClusterManifest:
88
+ if not nodes:
89
+ stamp = (
90
+ generated_at
91
+ if generated_at is not None
92
+ else datetime.now(UTC).isoformat(timespec="seconds")
93
+ )
94
+ return ClusterManifest(
95
+ generated_at=stamp,
96
+ corpus_sha256=corpus_sha256,
97
+ jaccard_threshold=jaccard_threshold,
98
+ total_clusters=0,
99
+ total_nodes_clustered=0,
100
+ singletons=0,
101
+ clusters=(),
102
+ )
103
+
104
+ node_ids = [str(n["id"]) for n in nodes]
105
+ token_sets: dict[str, set[str]] = {
106
+ str(n["id"]): set(
107
+ _filter_tokens(
108
+ tokenize(str(n.get("section_body", ""))), min_token_length,
109
+ ),
110
+ )
111
+ for n in nodes
112
+ }
113
+ adj: dict[str, set[str]] = {nid: set() for nid in node_ids}
114
+ for i, a in enumerate(node_ids):
115
+ for b in node_ids[i + 1 :]:
116
+ sim = _jaccard(token_sets[a], token_sets[b])
117
+ if sim >= jaccard_threshold:
118
+ adj[a].add(b)
119
+ adj[b].add(a)
120
+
121
+ components = _connected_components(node_ids, adj)
122
+ node_by_id = {str(n["id"]): n for n in nodes}
123
+
124
+ clusters: list[Cluster] = []
125
+ singletons = 0
126
+ for comp in components:
127
+ member_ids = sorted(comp)
128
+ nodes_data = [
129
+ {
130
+ "tokens": _filter_tokens(
131
+ tokenize(str(node_by_id[nid].get("section_body", ""))),
132
+ min_token_length,
133
+ ),
134
+ "tags": tuple(node_by_id[nid].get("tags", ())),
135
+ }
136
+ for nid in member_ids
137
+ ]
138
+ label, top_tokens, dominant = _label_cluster(nodes_data)
139
+ cluster_id = hashlib.sha256(
140
+ "|".join(member_ids).encode(),
141
+ ).hexdigest()[:12]
142
+ if len(member_ids) == 1:
143
+ singletons += 1
144
+ clusters.append(
145
+ Cluster(
146
+ cluster_id=cluster_id,
147
+ label=label,
148
+ dominant_domain=dominant,
149
+ top_tokens=top_tokens,
150
+ node_count=len(member_ids),
151
+ member_node_ids=tuple(member_ids),
152
+ ),
153
+ )
154
+ clusters.sort(key=lambda c: (-c.node_count, c.cluster_id))
155
+
156
+ stamp = (
157
+ generated_at
158
+ if generated_at is not None
159
+ else datetime.now(UTC).isoformat(timespec="seconds")
160
+ )
161
+ return ClusterManifest(
162
+ generated_at=stamp,
163
+ corpus_sha256=corpus_sha256,
164
+ jaccard_threshold=jaccard_threshold,
165
+ total_clusters=len(clusters),
166
+ total_nodes_clustered=len(node_ids),
167
+ singletons=singletons,
168
+ clusters=tuple(clusters),
169
+ )
codeforge/kb/code_graph.py ADDED
@@ -0,0 +1,109 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import ast
4
+ from typing import Any
5
+
6
+ import networkx as nx # type: ignore[import-untyped]
7
+
8
+
9
+ def build_code_graph(files: dict[str, str]) -> nx.DiGraph:
10
+ """Build a structural graph from Python source files.
11
+
12
+ Nodes: modules, functions, classes
13
+ Edges: imports, calls, inheritance, exports
14
+ """
15
+ g: nx.DiGraph = nx.DiGraph()
16
+ for filename, source in files.items():
17
+ module = filename.removesuffix(".py")
18
+ g.add_node(module, kind="module")
19
+ try:
20
+ tree = ast.parse(source, filename=filename)
21
+ except SyntaxError:
22
+ continue
23
+ for node in ast.walk(tree):
24
+ if isinstance(node, ast.FunctionDef):
25
+ fqn = f"{module}.{node.name}"
26
+ g.add_node(fqn, kind="function", line=node.lineno)
27
+ g.add_edge(module, fqn, relation="exports")
28
+ elif isinstance(node, ast.ClassDef):
29
+ fqn = f"{module}.{node.name}"
30
+ g.add_node(fqn, kind="class", line=node.lineno)
31
+ g.add_edge(module, fqn, relation="exports")
32
+ for base in node.bases:
33
+ if isinstance(base, ast.Name):
34
+ g.add_edge(fqn, base.id, relation="inherits")
35
+ elif isinstance(node, ast.ImportFrom) and node.module:
36
+ g.add_edge(module, node.module, relation="imports")
37
+ elif isinstance(node, ast.Import):
38
+ for alias in node.names:
39
+ g.add_edge(module, alias.name, relation="imports")
40
+ return g
41
+
42
+
43
+ def query_graph(g: nx.DiGraph, question: str) -> list[dict[str, Any]]:
44
+ """Structural queries on the code graph.
45
+
46
+ Supported question prefixes:
47
+ - "exports_of <module>" -- functions/classes exported by module
48
+ - "imports_of <module>" -- modules imported by module
49
+ - "dependents_of <module>" -- modules that import this module
50
+ - "all_modules" -- list all module nodes
51
+ - "all_functions" -- list all function nodes
52
+ - "all_classes" -- list all class nodes
53
+ """
54
+ parts = question.strip().split(maxsplit=1)
55
+ if len(parts) < 1 or not parts[0]:
56
+ return []
57
+
58
+ cmd = parts[0].lower()
59
+ target = parts[1] if len(parts) > 1 else ""
60
+
61
+ if cmd == "exports_of":
62
+ if target not in g:
63
+ return []
64
+ return [
65
+ {"node": n, **g.nodes[n]}
66
+ for n in g.successors(target)
67
+ if g.edges[target, n].get("relation") == "exports"
68
+ ]
69
+
70
+ if cmd == "imports_of":
71
+ if target not in g:
72
+ return []
73
+ return [
74
+ {"node": n}
75
+ for n in g.successors(target)
76
+ if g.edges[target, n].get("relation") == "imports"
77
+ ]
78
+
79
+ if cmd == "dependents_of":
80
+ if target not in g:
81
+ return []
82
+ return [
83
+ {"node": n}
84
+ for n in g.predecessors(target)
85
+ if g.edges[n, target].get("relation") == "imports"
86
+ ]
87
+
88
+ if cmd == "all_modules":
89
+ return [
90
+ {"node": n, **d}
91
+ for n, d in g.nodes(data=True)
92
+ if d.get("kind") == "module"
93
+ ]
94
+
95
+ if cmd == "all_functions":
96
+ return [
97
+ {"node": n, **d}
98
+ for n, d in g.nodes(data=True)
99
+ if d.get("kind") == "function"
100
+ ]
101
+
102
+ if cmd == "all_classes":
103
+ return [
104
+ {"node": n, **d}
105
+ for n, d in g.nodes(data=True)
106
+ if d.get("kind") == "class"
107
+ ]
108
+
109
+ return []
codeforge/kb/corpus_manager.py ADDED
@@ -0,0 +1,138 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import logging
5
+ from pathlib import Path
6
+ from typing import Any
7
+
8
+ from codeforge.scraper.discovery import SourceRoot, walk_sources
9
+ from codeforge.scraper.parser import ParseError, parse_skill
10
+ from codeforge.scraper.pipeline import _dedupe, scrape_single_skill
11
+
12
+ _log = logging.getLogger(__name__)
13
+
14
+
15
+ class SkillCorpusManager:
16
+ """Manages the skill corpus: add, remove, refresh, save/load JSONL."""
17
+
18
+ def __init__(self, *, corpus_path: Path) -> None:
19
+ self._corpus_path = corpus_path
20
+ self._nodes: list[dict[str, Any]] = []
21
+
22
+ @property
23
+ def nodes(self) -> list[dict[str, Any]]:
24
+ """Read-only access to the current node list."""
25
+ return list(self._nodes)
26
+
27
+ def load(self) -> None:
28
+ """Load existing corpus from JSONL file."""
29
+ if not self._corpus_path.is_file():
30
+ self._nodes = []
31
+ return
32
+ nodes: list[dict[str, Any]] = []
33
+ with self._corpus_path.open(encoding="utf-8") as f:
34
+ for line in f:
35
+ line = line.strip()
36
+ if not line:
37
+ continue
38
+ nodes.append(json.loads(line))
39
+ self._nodes = nodes
40
+
41
+ def add_skill(self, path: Path) -> int:
42
+ """Scrape a single SKILL.md, append nodes to corpus. Returns count added."""
43
+ new_nodes = scrape_single_skill(path)
44
+ if not new_nodes:
45
+ return 0
46
+ combined = list(self._nodes) + new_nodes
47
+ deduped = _dedupe(combined)
48
+ added = len(deduped) - len(self._nodes)
49
+ self._nodes = deduped
50
+ return max(added, 0)
51
+
52
+ def remove_skill(self, skill_name: str) -> int:
53
+ """Remove all nodes for skill_name. Returns count removed."""
54
+ before = len(self._nodes)
55
+ self._nodes = [n for n in self._nodes if n["skill_name"] != skill_name]
56
+ return before - len(self._nodes)
57
+
58
+ def refresh(
59
+ self, *, sources: list[SourceRoot] | None = None
60
+ ) -> dict[str, int]:
61
+ """Diff disk sources vs corpus by mtime/body_hash.
62
+
63
+ Returns {added, removed, unchanged}.
64
+ """
65
+ if sources is None:
66
+ sources = []
67
+
68
+ # Build a map of current corpus keyed by source_path
69
+ existing_by_path: dict[str, list[dict[str, Any]]] = {}
70
+ for node in self._nodes:
71
+ sp = node.get("source_path", "")
72
+ existing_by_path.setdefault(sp, []).append(node)
73
+
74
+ # Discover what's on disk
75
+ disk_paths: set[str] = set()
76
+ disk_nodes: list[dict[str, Any]] = []
77
+ for path, root in walk_sources(sources):
78
+ disk_paths.add(str(path))
79
+ try:
80
+ parse_skill(path)
81
+ except ParseError:
82
+ continue
83
+ file_nodes = scrape_single_skill(path)
84
+ for n in file_nodes:
85
+ n["source_root"] = root.label
86
+ disk_nodes.extend(file_nodes)
87
+
88
+ # Nodes from sources not on disk anymore -> removed
89
+ removed_count = 0
90
+ kept: list[dict[str, Any]] = []
91
+ for node in self._nodes:
92
+ sp = node.get("source_path", "")
93
+ if sp in disk_paths:
94
+ # Check if body_hash or mtime changed
95
+ pass # handled below
96
+ elif sources:
97
+ # source_path no longer on disk via any source root
98
+ removed_count += 1
99
+ continue
100
+ kept.append(node)
101
+
102
+ # Determine unchanged vs changed via body_hash comparison
103
+ existing_hashes: set[tuple[str, str]] = set()
104
+ for node in kept:
105
+ existing_hashes.add(
106
+ (node.get("source_path", ""), node.get("body_hash", ""))
107
+ )
108
+
109
+ # Add new/changed nodes from disk
110
+ added_count = 0
111
+ for dn in disk_nodes:
112
+ key = (dn.get("source_path", ""), dn.get("body_hash", ""))
113
+ if key not in existing_hashes:
114
+ added_count += 1
115
+
116
+ # Replace nodes from disk-paths with fresh scraped versions
117
+ non_disk = [n for n in kept if n.get("source_path", "") not in disk_paths]
118
+ combined = non_disk + disk_nodes
119
+ deduped = _dedupe(combined)
120
+ unchanged = len(self._nodes) - removed_count - added_count
121
+ if unchanged < 0:
122
+ unchanged = 0
123
+
124
+ self._nodes = deduped
125
+ return {"added": added_count, "removed": removed_count, "unchanged": unchanged}
126
+
127
+ def save(self) -> None:
128
+ """Write corpus back to JSONL."""
129
+ self._corpus_path.parent.mkdir(parents=True, exist_ok=True)
130
+ sorted_nodes = sorted(self._nodes, key=lambda n: str(n["id"]))
131
+ with self._corpus_path.open("w", encoding="utf-8") as f:
132
+ for node in sorted_nodes:
133
+ f.write(json.dumps(node, default=str))
134
+ f.write("\n")
135
+
136
+ def node_count(self) -> int:
137
+ """Return current number of nodes in the corpus."""
138
+ return len(self._nodes)
codeforge/kb/indexer.py ADDED
@@ -0,0 +1,168 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import hashlib
4
+ import json
5
+ from pathlib import Path
6
+ from typing import Any
7
+
8
+ from rank_bm25 import BM25Okapi # type: ignore[import-untyped]
9
+
10
+ from codeforge.kb.models import Cluster, ClusterManifest, SearchResult
11
+ from codeforge.kb.tokenizer import tokenize
12
+
13
+
14
+ class SkillsIndex:
15
+ """BM25-backed full-text search over the skill documentation corpus."""
16
+
17
+ def __init__(self, *, corpus_path: Path) -> None:
18
+ self._corpus_path = corpus_path
19
+ self._nodes: list[dict[str, Any]] = []
20
+ self._tokenized: list[list[str]] = []
21
+ self._bm25: BM25Okapi | None = None
22
+ self._corpus_sha256: str = ""
23
+ self._cluster_manifest: ClusterManifest | None = None
24
+ self._node_to_cluster: dict[str, Cluster] = {}
25
+
26
+ def build(self) -> None:
27
+ """Load JSONL corpus and build BM25 index."""
28
+ if not self._corpus_path.is_file():
29
+ msg = f"corpus missing: {self._corpus_path}"
30
+ raise FileNotFoundError(msg)
31
+ self._corpus_sha256 = hashlib.sha256(
32
+ self._corpus_path.read_bytes(),
33
+ ).hexdigest()
34
+ self._nodes = []
35
+ self._tokenized = []
36
+ with self._corpus_path.open(encoding="utf-8") as f:
37
+ for line in f:
38
+ line = line.strip()
39
+ if not line:
40
+ continue
41
+ node: dict[str, Any] = json.loads(line)
42
+ self._nodes.append(node)
43
+ self._tokenized.append(
44
+ tokenize(str(node.get("section_body", ""))),
45
+ )
46
+ if self._tokenized:
47
+ self._bm25 = BM25Okapi(self._tokenized)
48
+
49
+ def search(
50
+ self,
51
+ query: str,
52
+ *,
53
+ top_k: int = 5,
54
+ required_tags: set[str] | None = None,
55
+ ) -> list[SearchResult]:
56
+ """BM25 search over corpus, optionally filtered by tags."""
57
+ q_tokens = tokenize(query)
58
+ if not q_tokens or self._bm25 is None or not self._nodes:
59
+ return []
60
+ candidates: list[int] = list(range(len(self._nodes)))
61
+ if required_tags:
62
+ candidates = [
63
+ i
64
+ for i in candidates
65
+ if required_tags.issubset(set(self._nodes[i].get("tags", [])))
66
+ ]
67
+ if not candidates:
68
+ return []
69
+ all_scores = self._bm25.get_scores(q_tokens)
70
+ scored = [(i, float(all_scores[i])) for i in candidates]
71
+ scored.sort(key=lambda pair: (-pair[1], self._nodes[pair[0]]["id"]))
72
+ top = scored[:top_k]
73
+ results: list[SearchResult] = []
74
+ for rank, (i, score) in enumerate(top, start=1):
75
+ node = self._nodes[i]
76
+ cluster = self._node_to_cluster.get(node["id"])
77
+ results.append(
78
+ SearchResult(
79
+ node_id=node["id"],
80
+ skill_name=node["skill_name"],
81
+ section_path=tuple(node["section_path"]),
82
+ section_body=node["section_body"],
83
+ tags=tuple(node["tags"]),
84
+ source_path=node["source_path"],
85
+ score=score,
86
+ rank=rank,
87
+ cluster_id=cluster.cluster_id if cluster else None,
88
+ ),
89
+ )
90
+ return results
91
+
92
+ def attach_cluster_manifest(self, manifest: ClusterManifest) -> None:
93
+ """Wire cluster assignments to nodes for search enrichment."""
94
+ self._cluster_manifest = manifest
95
+ self._node_to_cluster = {
96
+ nid: cluster
97
+ for cluster in manifest.clusters
98
+ for nid in cluster.member_node_ids
99
+ }
100
+
101
+ def cluster_by_label(self, label: str) -> Cluster | None:
102
+ """Look up a cluster by its label string."""
103
+ if self._cluster_manifest is None:
104
+ return None
105
+ for c in self._cluster_manifest.clusters:
106
+ if c.label == label:
107
+ return c
108
+ return None
109
+
110
+ def nodes_in_cluster(
111
+ self,
112
+ cluster_label: str,
113
+ top_k: int = 50,
114
+ ) -> list[SearchResult]:
115
+ """Return corpus nodes belonging to the named cluster."""
116
+ cluster = self.cluster_by_label(cluster_label)
117
+ if cluster is None:
118
+ return []
119
+ member_ids = set(cluster.member_node_ids)
120
+ results: list[SearchResult] = []
121
+ for node in self._nodes:
122
+ if node["id"] not in member_ids:
123
+ continue
124
+ results.append(
125
+ SearchResult(
126
+ node_id=node["id"],
127
+ skill_name=node["skill_name"],
128
+ section_path=tuple(node["section_path"]),
129
+ section_body=node["section_body"],
130
+ tags=tuple(node["tags"]),
131
+ source_path=node["source_path"],
132
+ score=0.0,
133
+ rank=len(results) + 1,
134
+ cluster_id=cluster.cluster_id,
135
+ ),
136
+ )
137
+ if len(results) >= top_k:
138
+ break
139
+ return results
140
+
141
+ def stats(self) -> dict[str, int | float]:
142
+ """Return index statistics: node_count, vocab_size, avg_doc_len."""
143
+ if not self._tokenized:
144
+ return {"node_count": 0, "vocab_size": 0, "avg_doc_len": 0.0}
145
+ vocab: set[str] = set()
146
+ total_len = 0
147
+ for toks in self._tokenized:
148
+ vocab.update(toks)
149
+ total_len += len(toks)
150
+ return {
151
+ "node_count": len(self._nodes),
152
+ "vocab_size": len(vocab),
153
+ "avg_doc_len": total_len / len(self._tokenized),
154
+ }
155
+
156
+ def all_cluster_labels(self) -> list[str]:
157
+ """Return all cluster labels (for MCP discovery tool)."""
158
+ if self._cluster_manifest is None:
159
+ return []
160
+ return [c.label for c in self._cluster_manifest.clusters]
161
+
162
+ def all_tags(self) -> set[str]:
163
+ """Return all unique tags across the corpus (for MCP discovery tool)."""
164
+ tags: set[str] = set()
165
+ for node in self._nodes:
166
+ for t in node.get("tags", []):
167
+ tags.add(str(t))
168
+ return tags
codeforge/kb/models.py ADDED
@@ -0,0 +1,37 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from pydantic import BaseModel, ConfigDict
4
+
5
+
6
+ class SearchResult(BaseModel):
7
+ model_config = ConfigDict(frozen=True)
8
+ node_id: str
9
+ skill_name: str
10
+ section_path: tuple[str, ...]
11
+ section_body: str
12
+ tags: tuple[str, ...]
13
+ source_path: str
14
+ score: float
15
+ rank: int
16
+ cluster_id: str | None = None
17
+
18
+
19
+ class Cluster(BaseModel):
20
+ model_config = ConfigDict(frozen=True)
21
+ cluster_id: str
22
+ label: str
23
+ dominant_domain: str
24
+ top_tokens: tuple[str, ...]
25
+ node_count: int
26
+ member_node_ids: tuple[str, ...]
27
+
28
+
29
+ class ClusterManifest(BaseModel):
30
+ model_config = ConfigDict(frozen=True)
31
+ generated_at: str
32
+ corpus_sha256: str
33
+ jaccard_threshold: float
34
+ total_clusters: int
35
+ total_nodes_clustered: int
36
+ singletons: int
37
+ clusters: tuple[Cluster, ...]
codeforge/kb/skills_corpus.jsonl ADDED
The diff for this file is too large to render. See raw diff
 
codeforge/kb/skills_corpus.manifest.json ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "generated_at": "2026-04-18T10:23:57+00:00",
3
+ "sources": [
4
+ {
5
+ "label": "ecc",
6
+ "glob": "CODEFORGE/everything-claude-code/skills/*/SKILL.md"
7
+ }
8
+ ],
9
+ "scraped_files": 183,
10
+ "skipped_files": 0,
11
+ "total_nodes": 2212,
12
+ "errors": [],
13
+ "corpus_sha256": "7cbc3be718ad4365b2061ee253e15d9f6de7e0e52c43198ffd021754c02029d3"
14
+ }
codeforge/kb/tokenizer.py ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import re
4
+
5
+ _SPLIT_RE = re.compile(r"[^\w]+", re.UNICODE)
6
+
7
+
8
+ def tokenize(text: str) -> list[str]:
9
+ return [t for t in _SPLIT_RE.split(text.lower()) if t]
codeforge/mcp_server.py ADDED
@@ -0,0 +1,848 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import logging
5
+ from collections.abc import Callable
6
+ from pathlib import Path
7
+ from typing import Any
8
+ from uuid import uuid4
9
+
10
+ from codeforge.environment import CodeForgeEnvironment
11
+ from codeforge.models import CodeForgeAction, CodeForgeActionType
12
+ from codeforge.ralph.synthesizer import Synthesizer
13
+ from codeforge.tasks import TASKS
14
+
15
+ _log = logging.getLogger(__name__)
16
+ _VERSION = "0.2.0"
17
+
18
+ _Handler = Callable[
19
+ ["CodeForgeMCPServer", "dict[str, Any]"],
20
+ "dict[str, Any]",
21
+ ]
22
+
23
+ _SID_DESC = "Session ID from codeforge_reset."
24
+ _SID_PROP: dict[str, str] = {
25
+ "type": "string",
26
+ "description": _SID_DESC,
27
+ }
28
+
29
+ # -------------------------------------------------------------------
30
+ # Tool schema definitions (SYSTEM_DESIGN §9.1)
31
+ # -------------------------------------------------------------------
32
+
33
+ _TOOL_DEFS: tuple[dict[str, Any], ...] = (
34
+ {
35
+ "name": "codeforge_reset",
36
+ "description": (
37
+ "Start a new CodeForge episode. You will receive a task "
38
+ "brief and initial files. Your goal is to produce working "
39
+ "Python code that passes sandbox verification. Budget is "
40
+ "limited — plan your actions carefully."
41
+ ),
42
+ "inputSchema": {
43
+ "type": "object",
44
+ "properties": {
45
+ "task_level": {
46
+ "type": "string",
47
+ "enum": ["easy", "medium", "hard"],
48
+ "description": (
49
+ "Difficulty level. Easy: single file, budget "
50
+ "4. Medium: multi-file with tests, budget 6. "
51
+ "Hard: three-file module, budget 10."
52
+ ),
53
+ },
54
+ },
55
+ "required": ["task_level"],
56
+ },
57
+ },
58
+ {
59
+ "name": "codeforge_query_kb",
60
+ "description": (
61
+ "Search the coding skills knowledge base. Returns real "
62
+ "documentation from 1006 skill nodes. Use this to find "
63
+ "patterns, best practices, and guidance BEFORE writing "
64
+ "code. Costs 1 budget unit. DO NOT guess library APIs — "
65
+ "search for them here or verify via documentation first."
66
+ ),
67
+ "inputSchema": {
68
+ "type": "object",
69
+ "properties": {
70
+ "session_id": _SID_PROP,
71
+ "claim": {
72
+ "type": "string",
73
+ "description": (
74
+ "What you want to find guidance on. Be "
75
+ "specific. Example: 'pytest fixture patterns "
76
+ "for testing greet functions'"
77
+ ),
78
+ },
79
+ "top_k": {
80
+ "type": "integer",
81
+ "default": 5,
82
+ "minimum": 1,
83
+ "maximum": 20,
84
+ "description": "Number of results to return",
85
+ },
86
+ "required_tags": {
87
+ "type": "array",
88
+ "items": {"type": "string"},
89
+ "default": [],
90
+ "description": (
91
+ "Only return nodes that have ALL of these "
92
+ "tags"
93
+ ),
94
+ },
95
+ },
96
+ "required": ["session_id", "claim"],
97
+ },
98
+ },
99
+ {
100
+ "name": "codeforge_query_cluster",
101
+ "description": (
102
+ "Browse a skill cluster by label. Clusters are communities "
103
+ "of related skill nodes grouped by Jaccard similarity. Use "
104
+ "this to explore a topic area deeply. Costs 1 budget unit."
105
+ ),
106
+ "inputSchema": {
107
+ "type": "object",
108
+ "properties": {
109
+ "session_id": _SID_PROP,
110
+ "cluster_label": {
111
+ "type": "string",
112
+ "description": (
113
+ "The cluster label to look up. Example: "
114
+ "'python_testing_pytest_fixtures'"
115
+ ),
116
+ },
117
+ "top_k": {
118
+ "type": "integer",
119
+ "default": 10,
120
+ "minimum": 1,
121
+ "maximum": 50,
122
+ },
123
+ },
124
+ "required": ["session_id", "cluster_label"],
125
+ },
126
+ },
127
+ {
128
+ "name": "codeforge_interrogate",
129
+ "description": (
130
+ "Get Socratic questions about the task that cite real skill "
131
+ "corpus nodes. Use this BEFORE writing code to identify "
132
+ "edge cases, success criteria, and assumptions you might be "
133
+ "wrong about. Costs 1 budget unit."
134
+ ),
135
+ "inputSchema": {
136
+ "type": "object",
137
+ "properties": {
138
+ "session_id": _SID_PROP,
139
+ "brief_override": {
140
+ "type": "string",
141
+ "description": (
142
+ "Optional override for the task brief. "
143
+ "If omitted, uses the current task brief."
144
+ ),
145
+ },
146
+ },
147
+ "required": ["session_id"],
148
+ },
149
+ },
150
+ {
151
+ "name": "codeforge_run_ralph",
152
+ "description": (
153
+ "Run autonomous improvement iterations on your current "
154
+ "code. Each iteration: synthesize improvement → "
155
+ "sandbox-score → keep if better. Costs max_iters budget "
156
+ "units. Wasted iterations (no improvement) cost 0.05 "
157
+ "penalty each. Use when you want the environment to "
158
+ "iteratively improve your code."
159
+ ),
160
+ "inputSchema": {
161
+ "type": "object",
162
+ "properties": {
163
+ "session_id": _SID_PROP,
164
+ "max_iters": {
165
+ "type": "integer",
166
+ "default": 3,
167
+ "minimum": 1,
168
+ "maximum": 10,
169
+ "description": (
170
+ "Maximum iterations. Each costs 1 budget. "
171
+ "Choose carefully."
172
+ ),
173
+ },
174
+ },
175
+ "required": ["session_id", "max_iters"],
176
+ },
177
+ },
178
+ {
179
+ "name": "codeforge_submit",
180
+ "description": (
181
+ "Submit Python files for grading. Your code will be: "
182
+ "(1) written to a sandbox and checked by ruff, mypy "
183
+ "--strict, pytest, and import resolution — these are REAL "
184
+ "tools, not mocks; (2) AST-grounded to verify every "
185
+ "import and attribute access resolves to a real Python "
186
+ "module/attribute; (3) scored via quality = 0.6*sandbox + "
187
+ "0.4*groundedness; (4) if you provide confidence, "
188
+ "Brier-penalized: reward = quality * (1 - "
189
+ "min((confidence-quality)^2, 0.5)). DO NOT fabricate "
190
+ "library names or API signatures — the grounder WILL "
191
+ "catch them and your score WILL drop."
192
+ ),
193
+ "inputSchema": {
194
+ "type": "object",
195
+ "properties": {
196
+ "session_id": _SID_PROP,
197
+ "files": {
198
+ "type": "object",
199
+ "additionalProperties": {"type": "string"},
200
+ "description": (
201
+ "Map of filename to file content. Example: "
202
+ '{"main.py": "def greet(name: str) -> str:'
203
+ "\\n return f'Hello, {name}!'\\n\"}"
204
+ ),
205
+ },
206
+ "confidence": {
207
+ "type": "number",
208
+ "minimum": 0.0,
209
+ "maximum": 1.0,
210
+ "description": (
211
+ "Your confidence that this submission is "
212
+ "correct (0.0 = no idea, 1.0 = certain). "
213
+ "Overconfidence on bad code is PENALIZED. "
214
+ "Honest uncertainty is treated more "
215
+ "favorably. If you are unsure, say so."
216
+ ),
217
+ },
218
+ },
219
+ "required": ["session_id", "files"],
220
+ },
221
+ },
222
+ {
223
+ "name": "codeforge_get_audit",
224
+ "description": (
225
+ "Read the audit trail for the current episode (or a "
226
+ "specific run). Returns every action taken, every citation "
227
+ "made, every reward earned, and the evidence behind each. "
228
+ "Costs 0 budget. Use this to review your progress and "
229
+ "understand what worked."
230
+ ),
231
+ "inputSchema": {
232
+ "type": "object",
233
+ "properties": {
234
+ "session_id": _SID_PROP,
235
+ "target_run_id": {
236
+ "type": "string",
237
+ "description": (
238
+ "Optional run ID to audit. "
239
+ "Defaults to current episode."
240
+ ),
241
+ },
242
+ },
243
+ "required": ["session_id"],
244
+ },
245
+ },
246
+ {
247
+ "name": "codeforge_state",
248
+ "description": (
249
+ "Get current episode state without taking an action. Shows "
250
+ "task brief, current files, budget remaining, last reward, "
251
+ "and whether the episode is done. Costs 0 budget."
252
+ ),
253
+ "inputSchema": {
254
+ "type": "object",
255
+ "properties": {"session_id": _SID_PROP},
256
+ "required": ["session_id"],
257
+ },
258
+ },
259
+ {
260
+ "name": "codeforge_list_clusters",
261
+ "description": (
262
+ "List all available cluster labels and their node counts. "
263
+ "Use this to discover what topic areas exist before "
264
+ "calling codeforge_query_cluster. Costs 0 budget."
265
+ ),
266
+ "inputSchema": {
267
+ "type": "object",
268
+ "properties": {"session_id": _SID_PROP},
269
+ },
270
+ },
271
+ {
272
+ "name": "codeforge_list_tags",
273
+ "description": (
274
+ "List all available tags in the skill corpus. Use this to "
275
+ "discover valid values for the required_tags parameter. "
276
+ "Costs 0 budget."
277
+ ),
278
+ "inputSchema": {
279
+ "type": "object",
280
+ "properties": {"session_id": _SID_PROP},
281
+ },
282
+ },
283
+ )
284
+
285
+ # -------------------------------------------------------------------
286
+ # Resource definitions
287
+ # -------------------------------------------------------------------
288
+
289
+ _RESOURCE_DEFS: tuple[dict[str, str], ...] = (
290
+ {
291
+ "uri": "codeforge://corpus/stats",
292
+ "name": "Corpus Statistics",
293
+ "description": (
294
+ "Corpus statistics (node count, vocab size, cluster count)"
295
+ ),
296
+ "mimeType": "application/json",
297
+ },
298
+ {
299
+ "uri": "codeforge://corpus/node/{node_id}",
300
+ "name": "Skill Node",
301
+ "description": (
302
+ "Full content of a specific skill node (free, no budget)"
303
+ ),
304
+ "mimeType": "application/json",
305
+ },
306
+ {
307
+ "uri": "codeforge://tasks",
308
+ "name": "Task Definitions",
309
+ "description": (
310
+ "Task definitions with briefs, budgets, targets, tools"
311
+ ),
312
+ "mimeType": "application/json",
313
+ },
314
+ {
315
+ "uri": "codeforge://audit/{episode_id}",
316
+ "name": "Audit Ledger",
317
+ "description": (
318
+ "Serialized audit ledger for a completed episode"
319
+ ),
320
+ "mimeType": "application/json",
321
+ },
322
+ )
323
+
324
+ # -------------------------------------------------------------------
325
+ # Prompt text
326
+ # -------------------------------------------------------------------
327
+
328
+ _SYSTEM_PROMPT_TEXT = (
329
+ "You are solving a CodeForge episode. Your code is graded by "
330
+ "REAL tools (ruff, mypy --strict, pytest, import resolution) in "
331
+ "a sandbox. Every import and attribute access is AST-grounded "
332
+ "against the real Python runtime. Overconfidence is penalized "
333
+ "via Brier scoring. Honest uncertainty about genuinely uncertain "
334
+ "results is rewarded.\n\n"
335
+ "Rules:\n"
336
+ "- DO NOT fabricate library names or API signatures — "
337
+ "the grounder catches them.\n"
338
+ "- DO NOT submit stubs (pass, ..., NotImplementedError) — "
339
+ "they score zero.\n"
340
+ "- Use codeforge_query_kb to find patterns BEFORE writing code.\n"
341
+ "- Use codeforge_interrogate to identify edge cases.\n"
342
+ "- Budget is limited. Plan actions carefully.\n"
343
+ "- If unsure of your confidence, set it low — "
344
+ "the grader rewards honesty.\n"
345
+ )
346
+
347
+ _SESSION_ERR = "Invalid session_id: {sid!r}. Call codeforge_reset first."
348
+
349
+
350
+ # -------------------------------------------------------------------
351
+ # Helpers
352
+ # -------------------------------------------------------------------
353
+
354
+
355
+ def _obs_to_dict(obs: Any) -> dict[str, Any]:
356
+ """Convert a CodeForgeObservation to a serializable dict."""
357
+ result: dict[str, Any] = json.loads(obs.model_dump_json())
358
+ return result
359
+
360
+
361
+ def _make_response(
362
+ obs: Any,
363
+ *,
364
+ session_id: str | None = None,
365
+ extra: dict[str, Any] | None = None,
366
+ ) -> dict[str, Any]:
367
+ """Build a versioned response dict from an observation."""
368
+ result: dict[str, Any] = {"_codeforge_version": _VERSION}
369
+ if session_id is not None:
370
+ result["session_id"] = session_id
371
+ result["observation"] = _obs_to_dict(obs)
372
+ if extra:
373
+ result.update(extra)
374
+ budget = result["observation"].get("budget_remaining", 0)
375
+ if isinstance(budget, int) and 0 < budget <= 2:
376
+ result["budget_warning"] = (
377
+ f"WARNING: {budget} budget remaining — plan carefully."
378
+ )
379
+ return result
380
+
381
+
382
+ def _session_error(sid: str) -> dict[str, Any]:
383
+ """Return an isError response for a missing session."""
384
+ return {
385
+ "isError": True,
386
+ "error": _SESSION_ERR.format(sid=sid),
387
+ "_codeforge_version": _VERSION,
388
+ }
389
+
390
+
391
+ def _require_session(
392
+ server: CodeForgeMCPServer,
393
+ arguments: dict[str, Any],
394
+ ) -> tuple[str, CodeForgeEnvironment | None]:
395
+ """Extract session_id and look up the environment."""
396
+ sid: str = arguments.get("session_id", "")
397
+ return sid, server._get_session(sid)
398
+
399
+
400
+ # -------------------------------------------------------------------
401
+ # CodeForgeMCPServer — embedded mode
402
+ # -------------------------------------------------------------------
403
+
404
+
405
+ class CodeForgeMCPServer:
406
+ """MCP server wrapping CodeForgeEnvironment (SYSTEM_DESIGN §9).
407
+
408
+ Embedded mode: imports CodeForgeEnvironment directly.
409
+ Each tool call is routed to a session-keyed environment.
410
+
411
+ **Universal LLM support:** The Ralph loop's synthesizer is configurable.
412
+ Whichever LLM connects to this MCP server can provide its own config::
413
+
414
+ # Ollama (local, free)
415
+ server = CodeForgeMCPServer(llm_provider="ollama", llm_model="llama3")
416
+
417
+ # OpenAI
418
+ server = CodeForgeMCPServer(llm_provider="openai", llm_model="gpt-4o")
419
+
420
+ # Anthropic
421
+ server = CodeForgeMCPServer(llm_provider="anthropic", llm_model="claude-sonnet-4-20250514")
422
+
423
+ # Any OpenAI-compatible (vLLM, LM Studio, Together, Groq)
424
+ server = CodeForgeMCPServer(
425
+ llm_provider="openai",
426
+ llm_base_url="http://localhost:8000/v1",
427
+ llm_model="my-model",
428
+ )
429
+
430
+ When no LLM config is provided, Ralph uses the deterministic
431
+ StubSynthesizer (no API calls needed).
432
+ """
433
+
434
+ def __init__(
435
+ self,
436
+ *,
437
+ corpus_path: Path | None = None,
438
+ max_sessions: int = 10,
439
+ llm_provider: str | None = None,
440
+ llm_api_key: str | None = None,
441
+ llm_base_url: str | None = None,
442
+ llm_model: str | None = None,
443
+ ) -> None:
444
+ self._corpus_path = corpus_path
445
+ self._sessions: dict[str, CodeForgeEnvironment] = {}
446
+ self._max_sessions = max_sessions
447
+ self._llm_provider = llm_provider
448
+ self._llm_api_key = llm_api_key
449
+ self._llm_base_url = llm_base_url
450
+ self._llm_model = llm_model
451
+
452
+ # -- Session management ------------------------------------------
453
+
454
+ def _get_session(
455
+ self,
456
+ session_id: str,
457
+ ) -> CodeForgeEnvironment | None:
458
+ return self._sessions.get(session_id)
459
+
460
+ def _create_session(
461
+ self,
462
+ ) -> tuple[str, CodeForgeEnvironment]:
463
+ sid = uuid4().hex[:16]
464
+ # Build synthesizer from LLM config (if provided)
465
+ synth: Synthesizer | None = None
466
+ if self._llm_provider:
467
+ from codeforge.ralph.synthesizer import LLMSynthesizer
468
+
469
+ synth = LLMSynthesizer(
470
+ provider=self._llm_provider,
471
+ api_key=self._llm_api_key,
472
+ base_url=self._llm_base_url,
473
+ model=self._llm_model,
474
+ )
475
+
476
+ env = CodeForgeEnvironment(
477
+ corpus_path=self._corpus_path,
478
+ synthesizer=synth,
479
+ )
480
+ if len(self._sessions) >= self._max_sessions:
481
+ oldest = next(iter(self._sessions))
482
+ del self._sessions[oldest]
483
+ self._sessions[sid] = env
484
+ return sid, env
485
+
486
+ # -- Public: definitions -----------------------------------------
487
+
488
+ def tool_definitions(self) -> list[dict[str, Any]]:
489
+ """Return tool schemas matching SYSTEM_DESIGN §9.1."""
490
+ return [dict(d) for d in _TOOL_DEFS]
491
+
492
+ def resource_definitions(self) -> list[dict[str, str]]:
493
+ """Return MCP resource definitions."""
494
+ return [dict(r) for r in _RESOURCE_DEFS]
495
+
496
+ def prompt_definitions(self) -> list[dict[str, Any]]:
497
+ """Return MCP prompt definitions."""
498
+ return [
499
+ {
500
+ "name": "codeforge_system",
501
+ "description": (
502
+ "System prompt injected at session start. "
503
+ "Contains task rules, budget constraints, "
504
+ "grading explanation."
505
+ ),
506
+ "arguments": [],
507
+ },
508
+ {
509
+ "name": "codeforge_task_brief",
510
+ "description": (
511
+ "Dynamic prompt populated with the current "
512
+ "task's brief, initial files, budget, target "
513
+ "score, and tool config."
514
+ ),
515
+ "arguments": [
516
+ {
517
+ "name": "session_id",
518
+ "description": _SID_DESC,
519
+ "required": True,
520
+ },
521
+ ],
522
+ },
523
+ ]
524
+
525
+ # -- Public: handle_tool -----------------------------------------
526
+
527
+ def handle_tool(
528
+ self,
529
+ tool_name: str,
530
+ arguments: dict[str, Any],
531
+ ) -> dict[str, Any]:
532
+ """Route tool call to handler, return result dict."""
533
+ handler = _HANDLERS.get(tool_name)
534
+ if handler is None:
535
+ return {
536
+ "isError": True,
537
+ "error": f"Unknown tool: {tool_name!r}",
538
+ "_codeforge_version": _VERSION,
539
+ }
540
+ return handler(self, arguments)
541
+
542
+ # -- Public: resources -------------------------------------------
543
+
544
+ def read_resource(
545
+ self,
546
+ uri: str,
547
+ *,
548
+ session_id: str | None = None,
549
+ ) -> dict[str, Any]:
550
+ """Read an MCP resource by URI."""
551
+ if uri == "codeforge://corpus/stats":
552
+ if session_id is None:
553
+ return {
554
+ "_codeforge_version": _VERSION,
555
+ "error": "session_id required for corpus stats",
556
+ }
557
+ env = self._get_session(session_id)
558
+ if env is None:
559
+ return {"_codeforge_version": _VERSION, "error": "Invalid session_id"}
560
+ idx = env._ensure_index()
561
+ stats = idx.stats()
562
+ cluster_count = len(idx.all_cluster_labels())
563
+ return {
564
+ "node_count": stats["node_count"],
565
+ "vocab_size": stats["vocab_size"],
566
+ "avg_doc_len": stats["avg_doc_len"],
567
+ "cluster_count": cluster_count,
568
+ }
569
+ if uri.startswith("codeforge://corpus/node/"):
570
+ node_id = uri.removeprefix("codeforge://corpus/node/")
571
+ if session_id is None:
572
+ return {
573
+ "_codeforge_version": _VERSION,
574
+ "error": "session_id required for node lookup",
575
+ }
576
+ env = self._get_session(session_id)
577
+ if env is None:
578
+ return {"_codeforge_version": _VERSION, "error": "Invalid session_id"}
579
+ idx = env._ensure_index()
580
+ for node in idx._nodes:
581
+ if node.get("id") == node_id:
582
+ return {"_codeforge_version": _VERSION, "node": node}
583
+ return {"_codeforge_version": _VERSION, "error": f"Node {node_id!r} not found"}
584
+ if uri == "codeforge://tasks":
585
+ return {
586
+ "_codeforge_version": _VERSION,
587
+ "tasks": [
588
+ {
589
+ "id": t.task_id,
590
+ "difficulty": t.task_level,
591
+ "brief": t.brief,
592
+ "target_score": t.target_score,
593
+ "max_budget": t.max_budget,
594
+ "tools": list(t.tools),
595
+ }
596
+ for t in TASKS
597
+ ],
598
+ }
599
+ if uri.startswith("codeforge://audit/"):
600
+ episode_id = uri.removeprefix("codeforge://audit/")
601
+ if session_id is None:
602
+ return {
603
+ "_codeforge_version": _VERSION,
604
+ "error": "session_id required for audit lookup",
605
+ }
606
+ env = self._get_session(session_id)
607
+ if env is None:
608
+ return {"_codeforge_version": _VERSION, "error": "Invalid session_id"}
609
+ if env._ledger is not None:
610
+ return {
611
+ "_codeforge_version": _VERSION,
612
+ "episode_id": episode_id,
613
+ "audit": env._ledger.serialize(),
614
+ }
615
+ return {
616
+ "_codeforge_version": _VERSION,
617
+ "error": "No audit data for this session",
618
+ }
619
+ return {"_codeforge_version": _VERSION, "error": f"Unknown resource URI: {uri!r}"}
620
+
621
+ # -- Public: prompts ---------------------------------------------
622
+
623
+ def get_prompt(
624
+ self,
625
+ name: str,
626
+ *,
627
+ session_id: str | None = None,
628
+ ) -> list[dict[str, str]]:
629
+ """Return prompt messages for the given prompt name."""
630
+ if name == "codeforge_system":
631
+ return [
632
+ {"role": "system", "content": _SYSTEM_PROMPT_TEXT},
633
+ ]
634
+ if name == "codeforge_task_brief":
635
+ if session_id is None:
636
+ return [
637
+ {
638
+ "role": "system",
639
+ "content": (
640
+ "Error: session_id required for "
641
+ "task_brief prompt."
642
+ ),
643
+ },
644
+ ]
645
+ env = self._get_session(session_id)
646
+ if env is None:
647
+ return [
648
+ {
649
+ "role": "system",
650
+ "content": "Error: invalid session_id.",
651
+ },
652
+ ]
653
+ obs = env.state
654
+ task = env._task
655
+ target = task.target_score if task is not None else 0.0
656
+ content = (
657
+ f"## Task: {obs.task_id}\n"
658
+ f"**Level:** {obs.task_level}\n"
659
+ f"**Brief:** {obs.task_brief}\n"
660
+ f"**Budget:** {obs.budget_remaining}\n"
661
+ f"**Target score:** {target}\n\n"
662
+ "### Initial files\n"
663
+ )
664
+ for fname, body in obs.initial_files.items():
665
+ content += (
666
+ f"\n**{fname}:**\n```python\n{body}\n```\n"
667
+ )
668
+ return [{"role": "system", "content": content}]
669
+ return [
670
+ {
671
+ "role": "system",
672
+ "content": f"Unknown prompt: {name!r}",
673
+ },
674
+ ]
675
+
676
+
677
+ # -------------------------------------------------------------------
678
+ # Tool handlers (private, keyed by tool name)
679
+ # -------------------------------------------------------------------
680
+
681
+
682
+ def _handle_reset(
683
+ server: CodeForgeMCPServer,
684
+ arguments: dict[str, Any],
685
+ ) -> dict[str, Any]:
686
+ task_level = arguments.get("task_level", "easy")
687
+ sid, env = server._create_session()
688
+ obs = env.reset(task_level=task_level)
689
+ return _make_response(obs, session_id=sid)
690
+
691
+
692
+ def _handle_query_kb(
693
+ server: CodeForgeMCPServer,
694
+ arguments: dict[str, Any],
695
+ ) -> dict[str, Any]:
696
+ sid, env = _require_session(server, arguments)
697
+ if env is None:
698
+ return _session_error(sid)
699
+ action = CodeForgeAction(
700
+ action_type=CodeForgeActionType.QUERY_KB,
701
+ claim=arguments.get("claim"),
702
+ top_k=arguments.get("top_k", 5),
703
+ required_tags=tuple(arguments.get("required_tags", ())),
704
+ )
705
+ obs = env.step(action)
706
+ return _make_response(obs, session_id=sid)
707
+
708
+
709
+ def _handle_query_cluster(
710
+ server: CodeForgeMCPServer,
711
+ arguments: dict[str, Any],
712
+ ) -> dict[str, Any]:
713
+ sid, env = _require_session(server, arguments)
714
+ if env is None:
715
+ return _session_error(sid)
716
+ action = CodeForgeAction(
717
+ action_type=CodeForgeActionType.QUERY_CLUSTER,
718
+ cluster_label=arguments.get("cluster_label"),
719
+ top_k=arguments.get("top_k", 10),
720
+ )
721
+ obs = env.step(action)
722
+ return _make_response(obs, session_id=sid)
723
+
724
+
725
+ def _handle_interrogate(
726
+ server: CodeForgeMCPServer,
727
+ arguments: dict[str, Any],
728
+ ) -> dict[str, Any]:
729
+ sid, env = _require_session(server, arguments)
730
+ if env is None:
731
+ return _session_error(sid)
732
+ action = CodeForgeAction(
733
+ action_type=CodeForgeActionType.INTERROGATE,
734
+ )
735
+ obs = env.step(action)
736
+ return _make_response(obs, session_id=sid)
737
+
738
+
739
+ def _handle_run_ralph(
740
+ server: CodeForgeMCPServer,
741
+ arguments: dict[str, Any],
742
+ ) -> dict[str, Any]:
743
+ sid, env = _require_session(server, arguments)
744
+ if env is None:
745
+ return _session_error(sid)
746
+ action = CodeForgeAction(
747
+ action_type=CodeForgeActionType.RUN_RALPH,
748
+ max_iters=arguments.get("max_iters", 3),
749
+ )
750
+ obs = env.step(action)
751
+ return _make_response(obs, session_id=sid)
752
+
753
+
754
+ def _handle_submit(
755
+ server: CodeForgeMCPServer,
756
+ arguments: dict[str, Any],
757
+ ) -> dict[str, Any]:
758
+ sid, env = _require_session(server, arguments)
759
+ if env is None:
760
+ return _session_error(sid)
761
+ action = CodeForgeAction(
762
+ action_type=CodeForgeActionType.SUBMIT,
763
+ files=arguments.get("files"),
764
+ confidence=arguments.get("confidence"),
765
+ )
766
+ obs = env.step(action)
767
+ return _make_response(obs, session_id=sid)
768
+
769
+
770
+ def _handle_get_audit(
771
+ server: CodeForgeMCPServer,
772
+ arguments: dict[str, Any],
773
+ ) -> dict[str, Any]:
774
+ sid, env = _require_session(server, arguments)
775
+ if env is None:
776
+ return _session_error(sid)
777
+ action = CodeForgeAction(
778
+ action_type=CodeForgeActionType.GET_AUDIT,
779
+ target_run_id=arguments.get("target_run_id"),
780
+ )
781
+ obs = env.step(action)
782
+ return _make_response(obs, session_id=sid)
783
+
784
+
785
+ def _handle_state(
786
+ server: CodeForgeMCPServer,
787
+ arguments: dict[str, Any],
788
+ ) -> dict[str, Any]:
789
+ sid, env = _require_session(server, arguments)
790
+ if env is None:
791
+ return _session_error(sid)
792
+ obs = env.state
793
+ return _make_response(obs, session_id=sid)
794
+
795
+
796
+ def _handle_list_clusters(
797
+ server: CodeForgeMCPServer,
798
+ arguments: dict[str, Any],
799
+ ) -> dict[str, Any]:
800
+ _sid, env = _require_session(server, arguments)
801
+ if env is None:
802
+ return {"_codeforge_version": _VERSION, "clusters": []}
803
+ try:
804
+ idx = env._ensure_index()
805
+ except FileNotFoundError:
806
+ return {"_codeforge_version": _VERSION, "clusters": []}
807
+ labels = idx.all_cluster_labels()
808
+ cluster_info: list[dict[str, Any]] = []
809
+ for label in labels:
810
+ cluster = idx.cluster_by_label(label)
811
+ if cluster is not None:
812
+ cluster_info.append({
813
+ "label": cluster.label,
814
+ "node_count": cluster.node_count,
815
+ })
816
+ return {"_codeforge_version": _VERSION, "clusters": cluster_info}
817
+
818
+
819
+ def _handle_list_tags(
820
+ server: CodeForgeMCPServer,
821
+ arguments: dict[str, Any],
822
+ ) -> dict[str, Any]:
823
+ _sid, env = _require_session(server, arguments)
824
+ if env is None:
825
+ return {"_codeforge_version": _VERSION, "tags": []}
826
+ try:
827
+ idx = env._ensure_index()
828
+ except FileNotFoundError:
829
+ return {"_codeforge_version": _VERSION, "tags": []}
830
+ return {
831
+ "_codeforge_version": _VERSION,
832
+ "tags": sorted(idx.all_tags()),
833
+ }
834
+
835
+
836
+ # Handler dispatch table
837
+ _HANDLERS: dict[str, _Handler] = {
838
+ "codeforge_reset": _handle_reset,
839
+ "codeforge_query_kb": _handle_query_kb,
840
+ "codeforge_query_cluster": _handle_query_cluster,
841
+ "codeforge_interrogate": _handle_interrogate,
842
+ "codeforge_run_ralph": _handle_run_ralph,
843
+ "codeforge_submit": _handle_submit,
844
+ "codeforge_get_audit": _handle_get_audit,
845
+ "codeforge_state": _handle_state,
846
+ "codeforge_list_clusters": _handle_list_clusters,
847
+ "codeforge_list_tags": _handle_list_tags,
848
+ }
codeforge/models.py ADDED
@@ -0,0 +1,73 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from enum import StrEnum
5
+
6
+ from openenv.core.env_server.types import Action, Observation
7
+ from pydantic import Field
8
+
9
+
10
+ class CodeForgeActionType(StrEnum):
11
+ QUERY_KB = "query_kb"
12
+ QUERY_CLUSTER = "query_cluster"
13
+ INTERROGATE = "interrogate"
14
+ RUN_RALPH = "run_ralph"
15
+ SUBMIT = "submit"
16
+ GET_AUDIT = "get_audit"
17
+
18
+
19
+ class CodeForgeAction(Action):
20
+ action_type: CodeForgeActionType
21
+ # query_kb fields
22
+ claim: str | None = None
23
+ top_k: int = 5
24
+ required_tags: tuple[str, ...] = ()
25
+ # submit fields
26
+ files: dict[str, str] | None = None
27
+ confidence: float | None = Field(default=None, ge=0.0, le=1.0)
28
+ # query_cluster fields
29
+ cluster_label: str | None = None
30
+ # run_ralph fields
31
+ max_iters: int = Field(default=3, ge=1, le=10)
32
+ # get_audit fields
33
+ target_run_id: str | None = None
34
+
35
+
36
+ class CodeForgeObservation(Observation):
37
+ episode_id: str
38
+ task_id: str
39
+ task_level: str
40
+ task_brief: str
41
+ initial_files: dict[str, str]
42
+ current_files: dict[str, str]
43
+ budget_remaining: int
44
+ previous_score: float
45
+ last_reward: float
46
+ is_done: bool
47
+ # KB results
48
+ last_citations: tuple[dict[str, object], ...] = ()
49
+ last_grounding: dict[str, object] | None = None
50
+ # Cluster results
51
+ last_cluster_hits: tuple[str, ...] = ()
52
+ # Interrogation results
53
+ last_interrogation_questions: tuple[str, ...] = ()
54
+ # Ralph results
55
+ last_ralph_run_id: str | None = None
56
+ last_ralph_iterations: tuple[dict[str, object], ...] = ()
57
+ # Audit summary
58
+ cumulative_audit_summary: dict[str, object] = Field(default_factory=dict)
59
+ # Error field
60
+ error: str | None = None
61
+
62
+
63
+ @dataclass(frozen=True)
64
+ class AuditEntry:
65
+ step_index: int
66
+ action_type: str
67
+ cited_skill_ids: tuple[str, ...]
68
+ cited_clusters: tuple[str, ...]
69
+ grounding_report: dict[str, object] | None
70
+ reward: float
71
+ brier_penalty: float | None
72
+ confidence_declared: float | None
73
+ quality: float
codeforge/observation.py ADDED
@@ -0,0 +1,50 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from typing import TYPE_CHECKING
4
+
5
+ from codeforge.models import CodeForgeObservation
6
+
7
+ if TYPE_CHECKING:
8
+ from codeforge.tasks import Task
9
+
10
+
11
+ def build_observation(
12
+ *,
13
+ episode_id: str,
14
+ task: Task,
15
+ current_files: dict[str, str],
16
+ budget_remaining: int,
17
+ previous_score: float,
18
+ last_citations: tuple[dict[str, object], ...] = (),
19
+ last_grounding: dict[str, object] | None = None,
20
+ is_done: bool = False,
21
+ last_reward: float = 0.0,
22
+ last_cluster_hits: tuple[str, ...] = (),
23
+ last_interrogation_questions: tuple[str, ...] = (),
24
+ last_ralph_run_id: str | None = None,
25
+ last_ralph_iterations: tuple[dict[str, object], ...] = (),
26
+ cumulative_audit_summary: dict[str, object] | None = None,
27
+ error: str | None = None,
28
+ ) -> CodeForgeObservation:
29
+ return CodeForgeObservation(
30
+ episode_id=episode_id,
31
+ task_id=task.task_id,
32
+ task_level=task.task_level,
33
+ task_brief=task.brief,
34
+ initial_files=dict(task.initial_files),
35
+ current_files=dict(current_files),
36
+ budget_remaining=budget_remaining,
37
+ previous_score=previous_score,
38
+ last_citations=last_citations,
39
+ last_grounding=last_grounding,
40
+ is_done=is_done,
41
+ last_reward=last_reward,
42
+ last_cluster_hits=last_cluster_hits,
43
+ last_interrogation_questions=last_interrogation_questions,
44
+ last_ralph_run_id=last_ralph_run_id,
45
+ last_ralph_iterations=last_ralph_iterations,
46
+ cumulative_audit_summary=cumulative_audit_summary or {},
47
+ error=error,
48
+ reward=last_reward,
49
+ done=is_done,
50
+ )
codeforge/ralph/__init__.py ADDED
@@ -0,0 +1,17 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from codeforge.ralph.loop import run_loop
4
+ from codeforge.ralph.models import LoopConfig, RunResult
5
+ from codeforge.ralph.planner import Planner, Subtask
6
+ from codeforge.ralph.synthesizer import LLMSynthesizer, StubSynthesizer, Synthesizer
7
+
8
+ __all__ = [
9
+ "LLMSynthesizer",
10
+ "LoopConfig",
11
+ "Planner",
12
+ "RunResult",
13
+ "StubSynthesizer",
14
+ "Subtask",
15
+ "Synthesizer",
16
+ "run_loop",
17
+ ]
codeforge/ralph/checkpoint.py ADDED
@@ -0,0 +1,23 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ from pathlib import Path
5
+
6
+ from codeforge.ralph.models import RunResult
7
+
8
+
9
+ def save_checkpoint(run: RunResult, checkpoint_dir: Path) -> Path:
10
+ """Atomically write a RunResult to ``checkpoint_dir/run_{run_id}.json``."""
11
+ checkpoint_dir.mkdir(parents=True, exist_ok=True)
12
+ final = checkpoint_dir / f"run_{run.run_id}.json"
13
+ tmp = final.with_suffix(".json.tmp")
14
+ tmp.write_text(run.model_dump_json(indent=2), encoding="utf-8")
15
+ os.replace(tmp, final)
16
+ return final
17
+
18
+
19
+ def load_checkpoint(checkpoint_path: Path) -> RunResult:
20
+ """Load a RunResult from a checkpoint JSON file."""
21
+ return RunResult.model_validate_json(
22
+ checkpoint_path.read_text(encoding="utf-8"),
23
+ )
codeforge/ralph/loop.py ADDED
@@ -0,0 +1,167 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+ import uuid
5
+ from collections.abc import Mapping
6
+ from datetime import UTC, datetime
7
+ from pathlib import Path
8
+ from typing import TYPE_CHECKING
9
+
10
+ from codeforge.ralph.checkpoint import save_checkpoint
11
+ from codeforge.ralph.models import (
12
+ Iteration,
13
+ IterationReason,
14
+ LoopConfig,
15
+ RunResult,
16
+ TerminationReason,
17
+ )
18
+ from codeforge.sandbox.sandbox import run_sandbox
19
+
20
+ if TYPE_CHECKING:
21
+ from codeforge.kb.indexer import SkillsIndex
22
+ from codeforge.ralph.synthesizer import Synthesizer
23
+
24
+ _log = logging.getLogger(__name__)
25
+ _STUCK_THRESHOLD = 3
26
+
27
+
28
+ def _score_files(files: Mapping[str, str], config: LoopConfig) -> float:
29
+ """Score files via the real sandbox. Returns 0.0 on any error."""
30
+ try:
31
+ result = run_sandbox(
32
+ files=dict(files),
33
+ tools=config.tools,
34
+ timeout_per_tool=config.timeout_per_tool,
35
+ )
36
+ except Exception as e:
37
+ _log.exception("sandbox error: %s", e)
38
+ return 0.0
39
+ return result.composite_score
40
+
41
+
42
+ def run_loop(
43
+ *,
44
+ spec: str,
45
+ initial_files: Mapping[str, str],
46
+ index: SkillsIndex,
47
+ synthesizer: Synthesizer,
48
+ config: LoopConfig | None = None,
49
+ checkpoint_dir: Path | None = None,
50
+ ) -> RunResult:
51
+ """Run the score-gated retry loop.
52
+
53
+ Each iteration: score current → synthesize → score proposed → keep if better.
54
+ Terminates on target_hit, max_iters, or stuck (3 consecutive regressions).
55
+ """
56
+ cfg = config or LoopConfig()
57
+ run_id = f"ralph_{uuid.uuid4().hex[:12]}"
58
+ started_at = datetime.now(UTC).isoformat(timespec="seconds")
59
+
60
+ current: dict[str, str] = dict(initial_files)
61
+ iterations: list[Iteration] = []
62
+ consecutive_regressions = 0
63
+ terminated_by: TerminationReason = "max_iters"
64
+
65
+ for i in range(cfg.max_iters):
66
+ score_before = _score_files(current, cfg)
67
+ if score_before >= cfg.target_score:
68
+ terminated_by = "target_hit"
69
+ break
70
+
71
+ citations = index.search(spec, top_k=cfg.top_k_citations)
72
+
73
+ synth_reason: IterationReason | None = None
74
+ try:
75
+ synth = synthesizer.synthesize(
76
+ spec=spec,
77
+ current_files=current,
78
+ citations=citations,
79
+ iteration=i,
80
+ )
81
+ except Exception as e:
82
+ _log.exception("synthesizer error: %s", e)
83
+ synth = None
84
+ synth_reason = "synthesizer_error"
85
+
86
+ if synth is None:
87
+ iterations.append(
88
+ Iteration(
89
+ index=i,
90
+ cited_node_ids=(),
91
+ rationale="synth_error",
92
+ proposed_files=current,
93
+ sandbox_score_before=score_before,
94
+ sandbox_score_after=score_before,
95
+ kept=False,
96
+ reason=synth_reason or "synthesizer_error",
97
+ ),
98
+ )
99
+ consecutive_regressions += 1
100
+ else:
101
+ score_after = _score_files(synth.proposed_files, cfg)
102
+ reason: IterationReason
103
+ if score_after > score_before:
104
+ kept = True
105
+ reason = "score_improved"
106
+ consecutive_regressions = 0
107
+ current = dict(synth.proposed_files)
108
+ elif score_after < score_before:
109
+ kept = False
110
+ reason = "score_regressed"
111
+ consecutive_regressions += 1
112
+ else:
113
+ kept = False
114
+ reason = "score_plateau"
115
+ consecutive_regressions = 0
116
+ iterations.append(
117
+ Iteration(
118
+ index=i,
119
+ cited_node_ids=synth.cited_node_ids,
120
+ rationale=synth.rationale,
121
+ proposed_files=synth.proposed_files,
122
+ sandbox_score_before=score_before,
123
+ sandbox_score_after=score_after,
124
+ kept=kept,
125
+ reason=reason,
126
+ ),
127
+ )
128
+
129
+ if checkpoint_dir is not None:
130
+ try:
131
+ save_checkpoint(
132
+ RunResult(
133
+ run_id=run_id,
134
+ spec=spec,
135
+ started_at=started_at,
136
+ ended_at=datetime.now(UTC).isoformat(timespec="seconds"),
137
+ final_score=iterations[-1].sandbox_score_after,
138
+ final_files=current,
139
+ iterations=tuple(iterations),
140
+ terminated_by="in_progress",
141
+ ),
142
+ checkpoint_dir,
143
+ )
144
+ except OSError as e:
145
+ _log.warning("checkpoint write failed: %s", e)
146
+
147
+ if consecutive_regressions >= _STUCK_THRESHOLD:
148
+ terminated_by = "stuck"
149
+ break
150
+
151
+ final_score = _score_files(current, cfg)
152
+ result = RunResult(
153
+ run_id=run_id,
154
+ spec=spec,
155
+ started_at=started_at,
156
+ ended_at=datetime.now(UTC).isoformat(timespec="seconds"),
157
+ final_score=final_score,
158
+ final_files=current,
159
+ iterations=tuple(iterations),
160
+ terminated_by=terminated_by,
161
+ )
162
+ if checkpoint_dir is not None:
163
+ try:
164
+ save_checkpoint(result, checkpoint_dir)
165
+ except OSError as e:
166
+ _log.warning("final checkpoint write failed: %s", e)
167
+ return result
codeforge/ralph/models.py ADDED
@@ -0,0 +1,55 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from typing import Literal
4
+
5
+ from pydantic import BaseModel, ConfigDict, Field
6
+
7
+ TerminationReason = Literal["target_hit", "max_iters", "stuck", "in_progress"]
8
+ IterationReason = Literal[
9
+ "score_improved",
10
+ "score_regressed",
11
+ "score_plateau",
12
+ "target_hit",
13
+ "sandbox_error",
14
+ "synthesizer_error",
15
+ ]
16
+
17
+
18
+ class LoopConfig(BaseModel):
19
+ model_config = ConfigDict(frozen=True)
20
+ max_iters: int = Field(default=5, gt=0, le=100)
21
+ target_score: float = Field(default=0.95, gt=0.0, le=2.0)
22
+ tools: tuple[str, ...] = ("ruff", "imports")
23
+ timeout_per_tool: float = 60.0
24
+ top_k_citations: int = Field(default=5, gt=0, le=50)
25
+
26
+
27
+ class SynthesisResult(BaseModel):
28
+ model_config = ConfigDict(frozen=True)
29
+ proposed_files: dict[str, str]
30
+ rationale: str
31
+ cited_node_ids: tuple[str, ...]
32
+
33
+
34
+ class Iteration(BaseModel):
35
+ model_config = ConfigDict(frozen=True)
36
+ index: int
37
+ cited_node_ids: tuple[str, ...]
38
+ rationale: str
39
+ proposed_files: dict[str, str]
40
+ sandbox_score_before: float
41
+ sandbox_score_after: float
42
+ kept: bool
43
+ reason: IterationReason
44
+
45
+
46
+ class RunResult(BaseModel):
47
+ model_config = ConfigDict(frozen=True)
48
+ run_id: str
49
+ spec: str
50
+ started_at: str
51
+ ended_at: str
52
+ final_score: float
53
+ final_files: dict[str, str]
54
+ iterations: tuple[Iteration, ...]
55
+ terminated_by: TerminationReason
codeforge/ralph/planner.py ADDED
@@ -0,0 +1,90 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+
5
+
6
+ @dataclass(frozen=True)
7
+ class Subtask:
8
+ """A single step in a decomposed task plan."""
9
+
10
+ description: str
11
+ target_files: tuple[str, ...]
12
+ acceptance: str
13
+ tools: tuple[str, ...]
14
+
15
+
16
+ class Planner:
17
+ """Decomposes a task spec into ordered subtasks.
18
+
19
+ The planner analyzes the spec and initial files to determine:
20
+ 1. Which files need to be created/modified
21
+ 2. In what order (dependency-based)
22
+ 3. Which tools are relevant for scoring each subtask
23
+
24
+ This enables incremental scoring: implement core.py with ruff+mypy only,
25
+ then add tests with ruff+mypy+pytest.
26
+ """
27
+
28
+ def plan(
29
+ self,
30
+ spec: str,
31
+ initial_files: dict[str, str],
32
+ ) -> list[Subtask]:
33
+ """Decompose spec into ordered subtasks.
34
+
35
+ Strategy:
36
+ 1. Identify empty files (need implementation)
37
+ 2. Identify test files (depends on implementation files)
38
+ 3. Order: implementation files first, then test files
39
+ 4. Implementation files get ruff+mypy+imports tools
40
+ 5. Test files get ruff+mypy+imports+pytest tools
41
+ """
42
+ empty_files = [f for f, content in initial_files.items() if not content.strip()]
43
+
44
+ # Separate test files from implementation files
45
+ test_files = [f for f in empty_files if f.startswith("test_")]
46
+ impl_files = [f for f in empty_files if not f.startswith("test_")]
47
+
48
+ subtasks: list[Subtask] = []
49
+
50
+ # Phase 1: Implement empty non-test files
51
+ for f in impl_files:
52
+ subtasks.append(
53
+ Subtask(
54
+ description=f"Implement {f}",
55
+ target_files=(f,),
56
+ acceptance=f"{f} passes ruff, mypy --strict, and imports check",
57
+ tools=("ruff", "imports", "mypy"),
58
+ )
59
+ )
60
+
61
+ # Phase 2: Write tests
62
+ for f in test_files:
63
+ subtasks.append(
64
+ Subtask(
65
+ description=f"Write tests in {f}",
66
+ target_files=(f,),
67
+ acceptance=f"{f} passes all tools including pytest",
68
+ tools=("ruff", "imports", "mypy", "pytest"),
69
+ )
70
+ )
71
+
72
+ # If no subtasks were generated, create one for everything
73
+ if not subtasks:
74
+ all_files = tuple(initial_files.keys())
75
+ has_tests = any(f.startswith("test_") for f in initial_files)
76
+ tools = (
77
+ ("ruff", "imports", "mypy", "pytest")
78
+ if has_tests
79
+ else ("ruff", "imports", "mypy")
80
+ )
81
+ subtasks.append(
82
+ Subtask(
83
+ description="Implement the complete task",
84
+ target_files=all_files,
85
+ acceptance="All tools pass",
86
+ tools=tools,
87
+ )
88
+ )
89
+
90
+ return subtasks
codeforge/ralph/synthesizer.py ADDED
@@ -0,0 +1,355 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ import re
5
+ from collections.abc import Mapping, Sequence
6
+ from typing import TYPE_CHECKING, Protocol
7
+
8
+ from codeforge.ralph.models import SynthesisResult
9
+
10
+ if TYPE_CHECKING:
11
+ from codeforge.kb.models import SearchResult
12
+
13
+ _FENCED_PY_RE = re.compile(r"```python\n(.*?)\n```", re.DOTALL)
14
+
15
+ # Matches '# filename: foo.py' or '## foo.py' immediately before a fenced block
16
+ _FILENAME_HEADER_RE = re.compile(
17
+ r"(?:^#{1,2}\s+(?:filename:\s*)?(\S+\.py)\s*$)",
18
+ re.MULTILINE,
19
+ )
20
+
21
+
22
+ class Synthesizer(Protocol):
23
+ """Abstract synthesizer interface for Ralph loop."""
24
+
25
+ def synthesize(
26
+ self,
27
+ *,
28
+ spec: str,
29
+ current_files: Mapping[str, str],
30
+ citations: Sequence[SearchResult],
31
+ iteration: int,
32
+ ) -> SynthesisResult: ...
33
+
34
+
35
+ class StubSynthesizer:
36
+ """Deterministic, KB-grounded stub. No LLM. Used in tests and as default."""
37
+
38
+ def synthesize(
39
+ self,
40
+ *,
41
+ spec: str,
42
+ current_files: Mapping[str, str],
43
+ citations: Sequence[SearchResult],
44
+ iteration: int,
45
+ ) -> SynthesisResult:
46
+ del spec, iteration # inputs retained for protocol; stub ignores
47
+
48
+ if not citations:
49
+ return SynthesisResult(
50
+ proposed_files=dict(current_files),
51
+ rationale="no_citations",
52
+ cited_node_ids=(),
53
+ )
54
+
55
+ top = citations[0]
56
+ main = current_files.get("main.py", "")
57
+ blocks = _FENCED_PY_RE.findall(top.section_body)
58
+
59
+ wrapper_name = f"_from_{top.skill_name.replace('-', '_')}_{top.rank}"
60
+ if blocks and wrapper_name not in main:
61
+ body = "\n".join(
62
+ f" {ln}" if ln.strip() else "" for ln in blocks[0].splitlines()
63
+ )
64
+ snippet = f"\n\ndef {wrapper_name}() -> None:\n{body}\n"
65
+ new_main = main + snippet
66
+ rationale = (
67
+ f"Applied suggestion from "
68
+ f"{top.skill_name}/{'/'.join(top.section_path)}"
69
+ )
70
+ elif blocks:
71
+ new_main = main
72
+ rationale = (
73
+ f"Already applied "
74
+ f"{top.skill_name}/{'/'.join(top.section_path)}"
75
+ )
76
+ else:
77
+ comment = f"# consulted: {top.skill_name}/{'/'.join(top.section_path)}\n"
78
+ new_main = main + (comment if comment not in main else "")
79
+ rationale = (
80
+ f"Consulted "
81
+ f"{top.skill_name}/{'/'.join(top.section_path)} (no code block)"
82
+ )
83
+
84
+ new_files = {**current_files, "main.py": new_main}
85
+ return SynthesisResult(
86
+ proposed_files=new_files,
87
+ rationale=rationale,
88
+ cited_node_ids=(top.node_id,),
89
+ )
90
+
91
+
92
+ class LLMSynthesizer:
93
+ """Calls any LLM to produce improved code given spec + current files + citations.
94
+
95
+ Provider-agnostic: works with Ollama, OpenAI, Anthropic, or any
96
+ OpenAI-compatible API. Set *provider* to choose the backend:
97
+
98
+ - ``"openai"`` — OpenAI / OpenAI-compatible (default). Works with Ollama,
99
+ LM Studio, vLLM, Together, Groq, etc. Set *base_url* for local models.
100
+ - ``"anthropic"`` — Anthropic Claude API.
101
+ - ``"ollama"`` — Shortcut for Ollama (sets base_url to localhost:11434).
102
+
103
+ Examples::
104
+
105
+ # Ollama (local, no API key needed)
106
+ LLMSynthesizer(provider="ollama", model="llama3")
107
+
108
+ # OpenAI
109
+ LLMSynthesizer(provider="openai", model="gpt-4o")
110
+
111
+ # Anthropic
112
+ LLMSynthesizer(provider="anthropic", model="claude-sonnet-4-20250514")
113
+
114
+ # Any OpenAI-compatible endpoint (vLLM, LM Studio, Together, etc.)
115
+ LLMSynthesizer(
116
+ provider="openai",
117
+ base_url="http://localhost:8000/v1",
118
+ model="my-local-model",
119
+ )
120
+ """
121
+
122
+ def __init__(
123
+ self,
124
+ *,
125
+ provider: str = "openai",
126
+ api_key: str | None = None,
127
+ base_url: str | None = None,
128
+ model: str | None = None,
129
+ max_tokens: int = 4096,
130
+ ) -> None:
131
+ self._provider: str = provider.lower()
132
+ self._max_tokens: int = max_tokens
133
+
134
+ if self._provider == "ollama":
135
+ self._base_url = base_url or "http://localhost:11434/v1"
136
+ self._api_key = api_key or "ollama" # Ollama ignores this
137
+ self._model = model or "llama3"
138
+ elif self._provider == "anthropic":
139
+ self._base_url = base_url or "https://api.anthropic.com"
140
+ self._api_key = api_key or os.environ.get("ANTHROPIC_API_KEY", "")
141
+ self._model = model or "claude-sonnet-4-20250514"
142
+ else: # openai or any compatible
143
+ self._base_url = base_url or "https://api.openai.com/v1"
144
+ self._api_key = api_key or os.environ.get("OPENAI_API_KEY", "")
145
+ self._model = model or "gpt-4o"
146
+
147
+ # ------------------------------------------------------------------
148
+ # Public API (satisfies Synthesizer protocol)
149
+ # ------------------------------------------------------------------
150
+
151
+ def synthesize(
152
+ self,
153
+ *,
154
+ spec: str,
155
+ current_files: Mapping[str, str],
156
+ citations: Sequence[SearchResult],
157
+ iteration: int,
158
+ ) -> SynthesisResult:
159
+ """Build prompt, call LLM, parse response into *SynthesisResult*."""
160
+ prompt = self._build_prompt(spec, current_files, citations, iteration)
161
+ response_text = self._call_llm(prompt)
162
+ result = self._parse_response(response_text, citations)
163
+
164
+ # If no code blocks were parsed, fall back to current files unchanged.
165
+ if not result.proposed_files:
166
+ return SynthesisResult(
167
+ proposed_files=dict(current_files),
168
+ rationale=result.rationale or "No parseable code blocks in LLM response",
169
+ cited_node_ids=result.cited_node_ids,
170
+ )
171
+ return result
172
+
173
+ # ------------------------------------------------------------------
174
+ # Prompt construction
175
+ # ------------------------------------------------------------------
176
+
177
+ def _build_prompt(
178
+ self,
179
+ spec: str,
180
+ current_files: Mapping[str, str],
181
+ citations: Sequence[SearchResult],
182
+ iteration: int,
183
+ ) -> str:
184
+ """Build the synthesis prompt with spec, files, citations, and iteration."""
185
+ parts: list[str] = [
186
+ "You are a Python code synthesis assistant.",
187
+ f"Iteration: {iteration}",
188
+ "",
189
+ "## Task Specification",
190
+ spec,
191
+ ]
192
+
193
+ if current_files:
194
+ parts.append("")
195
+ parts.append("## Current Files")
196
+ for fname, content in current_files.items():
197
+ parts.append(f"\n### {fname}")
198
+ parts.append(f"```python\n{content}\n```")
199
+
200
+ if citations:
201
+ parts.append("")
202
+ parts.append("## Skill Corpus Citations")
203
+ for cit in citations:
204
+ parts.append(
205
+ f"\n### {cit.skill_name} / {'/'.join(cit.section_path)}"
206
+ f" (score={cit.score:.1f})"
207
+ )
208
+ parts.append(cit.section_body)
209
+
210
+ parts.append("")
211
+ parts.append("## Instructions")
212
+ parts.append(
213
+ "Produce improved Python files. For EACH file, emit a header "
214
+ "`# filename: <name>.py` followed by a fenced python code block. "
215
+ "After all files, write a short rationale explaining your changes."
216
+ )
217
+
218
+ return "\n".join(parts)
219
+
220
+ # ------------------------------------------------------------------
221
+ # LLM API call (provider-agnostic)
222
+ # ------------------------------------------------------------------
223
+
224
+ def _call_llm(self, prompt: str) -> str:
225
+ """Call the configured LLM provider.
226
+
227
+ Supports three backends:
228
+ - **openai / ollama**: OpenAI-compatible ``/chat/completions`` endpoint.
229
+ Works with Ollama, LM Studio, vLLM, Together, Groq, OpenAI, etc.
230
+ - **anthropic**: Anthropic ``/v1/messages`` endpoint.
231
+ """
232
+ if self._provider == "anthropic":
233
+ return self._call_anthropic(prompt)
234
+ return self._call_openai_compatible(prompt)
235
+
236
+ def _call_openai_compatible(self, prompt: str) -> str:
237
+ """OpenAI-compatible API (works with Ollama, LM Studio, vLLM, etc.)."""
238
+ import httpx
239
+
240
+ url = f"{self._base_url.rstrip('/')}/chat/completions"
241
+ headers: dict[str, str] = {"content-type": "application/json"}
242
+ if self._api_key and self._api_key != "ollama":
243
+ headers["Authorization"] = f"Bearer {self._api_key}"
244
+
245
+ resp = httpx.post(
246
+ url,
247
+ headers=headers,
248
+ json={
249
+ "model": self._model,
250
+ "max_tokens": self._max_tokens,
251
+ "messages": [{"role": "user", "content": prompt}],
252
+ },
253
+ timeout=120.0,
254
+ )
255
+ resp.raise_for_status()
256
+ data = resp.json()
257
+ return str(data["choices"][0]["message"]["content"])
258
+
259
+ def _call_anthropic(self, prompt: str) -> str:
260
+ """Anthropic Claude API."""
261
+ if not self._api_key:
262
+ msg = (
263
+ "ANTHROPIC_API_KEY not set. For local models, use "
264
+ "provider='ollama' or provider='openai' with base_url."
265
+ )
266
+ raise ValueError(msg)
267
+
268
+ try:
269
+ import anthropic
270
+
271
+ client = anthropic.Anthropic(api_key=self._api_key)
272
+ message = client.messages.create(
273
+ model=self._model,
274
+ max_tokens=self._max_tokens,
275
+ messages=[{"role": "user", "content": prompt}],
276
+ )
277
+ block = message.content[0]
278
+ return str(getattr(block, "text", ""))
279
+ except ImportError:
280
+ import httpx
281
+
282
+ resp = httpx.post(
283
+ f"{self._base_url.rstrip('/')}/v1/messages",
284
+ headers={
285
+ "x-api-key": self._api_key,
286
+ "anthropic-version": "2023-06-01",
287
+ "content-type": "application/json",
288
+ },
289
+ json={
290
+ "model": self._model,
291
+ "max_tokens": self._max_tokens,
292
+ "messages": [{"role": "user", "content": prompt}],
293
+ },
294
+ timeout=120.0,
295
+ )
296
+ resp.raise_for_status()
297
+ data: dict[str, object] = resp.json()
298
+ content = data["content"]
299
+ assert isinstance(content, list)
300
+ first = content[0]
301
+ assert isinstance(first, dict)
302
+ return str(first["text"])
303
+
304
+ # ------------------------------------------------------------------
305
+ # Response parsing
306
+ # ------------------------------------------------------------------
307
+
308
+ def _parse_response(
309
+ self,
310
+ text: str,
311
+ citations: Sequence[SearchResult],
312
+ ) -> SynthesisResult:
313
+ """Parse LLM response: extract fenced code blocks with filename headers."""
314
+ proposed_files: dict[str, str] = {}
315
+
316
+ # Strategy: find all filename headers and pair each with the next
317
+ # fenced python block.
318
+ header_positions: list[tuple[int, str]] = [
319
+ (m.start(), m.group(1))
320
+ for m in _FILENAME_HEADER_RE.finditer(text)
321
+ ]
322
+
323
+ code_blocks: list[tuple[int, str]] = [
324
+ (m.start(), m.group(1))
325
+ for m in _FENCED_PY_RE.finditer(text)
326
+ ]
327
+
328
+ if header_positions and code_blocks:
329
+ for hdr_pos, filename in header_positions:
330
+ # Find the first code block that follows this header
331
+ for blk_pos, code in code_blocks:
332
+ if blk_pos > hdr_pos:
333
+ proposed_files[filename] = code
334
+ break
335
+
336
+ # Extract rationale from non-code, non-header text
337
+ rationale_text = text
338
+ for _, code in code_blocks:
339
+ rationale_text = rationale_text.replace(f"```python\n{code}\n```", "")
340
+ for m in _FILENAME_HEADER_RE.finditer(rationale_text):
341
+ rationale_text = rationale_text.replace(m.group(0), "")
342
+ rationale = rationale_text.strip()
343
+ # Collapse to a single line for storage
344
+ rationale = " ".join(rationale.split())
345
+
346
+ if not proposed_files:
347
+ rationale = rationale or "No parseable code blocks in LLM response"
348
+
349
+ cited_node_ids = tuple(c.node_id for c in citations)
350
+
351
+ return SynthesisResult(
352
+ proposed_files=proposed_files,
353
+ rationale=rationale,
354
+ cited_node_ids=cited_node_ids,
355
+ )
codeforge/sandbox/__init__.py ADDED
@@ -0,0 +1,17 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from codeforge.sandbox.models import (
4
+ ImportReport,
5
+ ParsedResult,
6
+ SandboxResult,
7
+ ToolResult,
8
+ )
9
+ from codeforge.sandbox.sandbox import run_sandbox
10
+
11
+ __all__ = [
12
+ "ImportReport",
13
+ "ParsedResult",
14
+ "SandboxResult",
15
+ "ToolResult",
16
+ "run_sandbox",
17
+ ]
codeforge/sandbox/imports.py ADDED
@@ -0,0 +1,67 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import ast
4
+ import importlib.util
5
+ import logging
6
+ from typing import TYPE_CHECKING
7
+
8
+ from codeforge.sandbox.models import ImportReport
9
+
10
+ if TYPE_CHECKING:
11
+ from pathlib import Path
12
+
13
+ _log = logging.getLogger(__name__)
14
+
15
+
16
+ def _top_level(name: str) -> str:
17
+ return name.split(".", maxsplit=1)[0]
18
+
19
+
20
+ def _extract_imports(tree: ast.AST) -> set[str]:
21
+ out: set[str] = set()
22
+ for node in ast.walk(tree):
23
+ if isinstance(node, ast.Import):
24
+ for alias in node.names:
25
+ out.add(_top_level(alias.name))
26
+ elif isinstance(node, ast.ImportFrom):
27
+ if node.level != 0 or node.module is None:
28
+ continue
29
+ out.add(_top_level(node.module))
30
+ return out
31
+
32
+
33
+ def _local_modules(project_dir: Path) -> set[str]:
34
+ local: set[str] = set()
35
+ for py in project_dir.rglob("*.py"):
36
+ if py.name == "__init__.py":
37
+ local.add(py.parent.name)
38
+ else:
39
+ local.add(py.stem)
40
+ return local
41
+
42
+
43
+ def scan_imports(project_dir: Path) -> ImportReport:
44
+ by_file: dict[str, tuple[str, ...]] = {}
45
+ all_pkgs: set[str] = set()
46
+ total = 0
47
+
48
+ for py in sorted(project_dir.rglob("*.py")):
49
+ try:
50
+ tree = ast.parse(py.read_text(encoding="utf-8"))
51
+ except (SyntaxError, UnicodeDecodeError) as e:
52
+ _log.warning("imports: parse error %s: %s", py, e)
53
+ by_file[str(py.relative_to(project_dir))] = ("__parse_error__",)
54
+ continue
55
+ pkgs = _extract_imports(tree)
56
+ total += len(pkgs)
57
+ by_file[str(py.relative_to(project_dir))] = tuple(sorted(pkgs))
58
+ all_pkgs.update(pkgs)
59
+
60
+ local = _local_modules(project_dir)
61
+ unresolved = tuple(
62
+ sorted(
63
+ p for p in all_pkgs
64
+ if p not in local and importlib.util.find_spec(p) is None
65
+ )
66
+ )
67
+ return ImportReport(total=total, unresolved=unresolved, by_file=by_file)
codeforge/sandbox/metric.py ADDED
@@ -0,0 +1,40 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from typing import TYPE_CHECKING
4
+
5
+ if TYPE_CHECKING:
6
+ from codeforge.sandbox.models import SandboxResult
7
+
8
+
9
+ def composite_score(
10
+ result: SandboxResult,
11
+ *,
12
+ tools: tuple[str, ...] | None = None,
13
+ ) -> float:
14
+ """Compute composite score from sandbox results.
15
+
16
+ When tools is None, score all tools that were run (full-project scoring).
17
+ When tools is provided, score only those tools (subtask scoring).
18
+ This lets the planner score 'implement core.py' with only ruff+mypy+imports,
19
+ without pytest destroying the score because tests aren't written yet.
20
+ """
21
+ parsed = result.parsed
22
+ if tools is not None:
23
+ parsed = {k: v for k, v in parsed.items() if k in tools}
24
+ if not parsed:
25
+ return 0.0
26
+
27
+ # Penalty-only scoring (no double-counting with pass_rate)
28
+ imports_penalty = min(1.0, len(result.imports.unresolved) * 0.1)
29
+
30
+ ruff = parsed.get("ruff")
31
+ mypy = parsed.get("mypy")
32
+ pytest_result = parsed.get("pytest")
33
+
34
+ ruff_penalty = min(ruff.count, 20) / 40 if ruff else 0.0
35
+ mypy_penalty = min(mypy.count, 20) / 40 if mypy else 0.0
36
+ pytest_penalty = 0.5 if pytest_result and not pytest_result.ok else 0.0
37
+
38
+ # Start at 1.0, subtract penalties. No pass_rate to avoid double-counting.
39
+ raw = 1.0 - imports_penalty - ruff_penalty - mypy_penalty - pytest_penalty
40
+ return max(0.0, min(1.0, raw))
codeforge/sandbox/models.py ADDED
@@ -0,0 +1,41 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+ from pydantic import BaseModel, ConfigDict
6
+
7
+
8
+ class ToolResult(BaseModel):
9
+ model_config = ConfigDict(frozen=True)
10
+ name: str
11
+ argv: tuple[str, ...]
12
+ exit_code: int
13
+ stdout: str
14
+ stderr: str
15
+ duration_ms: int
16
+ timed_out: bool
17
+
18
+
19
+ class ParsedResult(BaseModel):
20
+ model_config = ConfigDict(frozen=True)
21
+ ok: bool
22
+ count: int
23
+ details: dict[str, Any]
24
+
25
+
26
+ class ImportReport(BaseModel):
27
+ model_config = ConfigDict(frozen=True)
28
+ total: int
29
+ unresolved: tuple[str, ...]
30
+ by_file: dict[str, tuple[str, ...]]
31
+
32
+
33
+ class SandboxResult(BaseModel):
34
+ model_config = ConfigDict(frozen=True)
35
+ project_dir: str
36
+ tools_run: tuple[str, ...]
37
+ tool_results: dict[str, ToolResult]
38
+ parsed: dict[str, ParsedResult]
39
+ imports: ImportReport
40
+ composite_score: float
41
+ generated_at: str
codeforge/sandbox/runner.py ADDED
@@ -0,0 +1,61 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ import subprocess
5
+ import time
6
+ from typing import TYPE_CHECKING
7
+
8
+ from codeforge.sandbox.models import ToolResult
9
+
10
+ if TYPE_CHECKING:
11
+ from pathlib import Path
12
+
13
+
14
+ def run_tool(
15
+ name: str,
16
+ argv: list[str],
17
+ *,
18
+ cwd: Path,
19
+ timeout: float = 60.0,
20
+ env_overrides: dict[str, str] | None = None,
21
+ ) -> ToolResult:
22
+ t0 = time.monotonic()
23
+ timed_out = False
24
+ stdout = ""
25
+ stderr = ""
26
+ exit_code = -1
27
+
28
+ try:
29
+ proc = subprocess.run(
30
+ argv,
31
+ cwd=str(cwd),
32
+ capture_output=True,
33
+ text=True,
34
+ timeout=timeout,
35
+ check=False,
36
+ env=None if env_overrides is None else {**_os_env(), **env_overrides},
37
+ )
38
+ exit_code = proc.returncode
39
+ stdout = proc.stdout
40
+ stderr = proc.stderr
41
+ except subprocess.TimeoutExpired as e:
42
+ timed_out = True
43
+ stdout = e.stdout.decode() if isinstance(e.stdout, bytes) else (e.stdout or "")
44
+ stderr = e.stderr.decode() if isinstance(e.stderr, bytes) else (e.stderr or "")
45
+ except (FileNotFoundError, OSError) as e:
46
+ stderr = f"binary not found: {e}"
47
+
48
+ duration_ms = int((time.monotonic() - t0) * 1000)
49
+ return ToolResult(
50
+ name=name,
51
+ argv=tuple(argv),
52
+ exit_code=exit_code,
53
+ stdout=stdout,
54
+ stderr=stderr,
55
+ duration_ms=duration_ms,
56
+ timed_out=timed_out,
57
+ )
58
+
59
+
60
+ def _os_env() -> dict[str, str]:
61
+ return dict(os.environ)
codeforge/sandbox/sandbox.py ADDED
@@ -0,0 +1,100 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import shutil
4
+ import tempfile
5
+ from datetime import UTC, datetime
6
+ from pathlib import Path
7
+ from typing import TYPE_CHECKING
8
+
9
+ from codeforge.sandbox.imports import scan_imports
10
+ from codeforge.sandbox.metric import composite_score
11
+ from codeforge.sandbox.models import (
12
+ ImportReport,
13
+ ParsedResult,
14
+ SandboxResult,
15
+ ToolResult,
16
+ )
17
+ from codeforge.sandbox.runner import run_tool
18
+ from codeforge.sandbox.tools import (
19
+ DEFAULT_TOOLS,
20
+ argv_for,
21
+ is_available,
22
+ parse,
23
+ )
24
+
25
+ if TYPE_CHECKING:
26
+ from collections.abc import Iterable
27
+
28
+
29
+ def run_sandbox(
30
+ *,
31
+ project_dir: Path | None = None,
32
+ files: dict[str, str] | None = None,
33
+ tools: Iterable[str] = DEFAULT_TOOLS,
34
+ timeout_per_tool: float = 60.0,
35
+ ) -> SandboxResult:
36
+ if (project_dir is None) == (files is None):
37
+ msg = "exactly one of project_dir / files must be set"
38
+ raise ValueError(msg)
39
+
40
+ tmp_root: Path | None = None
41
+ try:
42
+ if files is not None:
43
+ tmp_root = Path(tempfile.mkdtemp(prefix="codeforge_sandbox_"))
44
+ tmp_root_resolved = tmp_root.resolve()
45
+ for name, content in files.items():
46
+ target = (tmp_root / name).resolve()
47
+ if not target.is_relative_to(tmp_root_resolved):
48
+ msg = f"path escapes sandbox root: {name!r}"
49
+ raise ValueError(msg)
50
+ target.parent.mkdir(parents=True, exist_ok=True)
51
+ target.write_text(content, encoding="utf-8")
52
+ project_dir = tmp_root
53
+
54
+ assert project_dir is not None
55
+ tool_list = tuple(tools)
56
+ tool_results: dict[str, ToolResult] = {}
57
+ parsed_results: dict[str, ParsedResult] = {}
58
+ imports_report: ImportReport | None = None
59
+
60
+ for name in tool_list:
61
+ if name == "imports":
62
+ imports_report = scan_imports(project_dir)
63
+ parsed_results[name] = ParsedResult(
64
+ ok=len(imports_report.unresolved) == 0,
65
+ count=len(imports_report.unresolved),
66
+ details={"unresolved": list(imports_report.unresolved)},
67
+ )
68
+ continue
69
+ if not is_available(name):
70
+ tool_results[name] = ToolResult(
71
+ name=name, argv=(name,), exit_code=-1,
72
+ stdout="", stderr="binary not found",
73
+ duration_ms=0, timed_out=False,
74
+ )
75
+ parsed_results[name] = ParsedResult(
76
+ ok=False, count=0, details={"unavailable": True},
77
+ )
78
+ continue
79
+ argv = argv_for(name, project_dir)
80
+ tr = run_tool(name, argv, cwd=project_dir, timeout=timeout_per_tool)
81
+ tool_results[name] = tr
82
+ parsed_results[name] = parse(name, tr)
83
+
84
+ if imports_report is None:
85
+ imports_report = ImportReport(total=0, unresolved=(), by_file={})
86
+
87
+ result = SandboxResult(
88
+ project_dir=str(project_dir),
89
+ tools_run=tool_list,
90
+ tool_results=tool_results,
91
+ parsed=parsed_results,
92
+ imports=imports_report,
93
+ composite_score=0.0,
94
+ generated_at=datetime.now(UTC).isoformat(timespec="seconds"),
95
+ )
96
+ score = composite_score(result)
97
+ return result.model_copy(update={"composite_score": score})
98
+ finally:
99
+ if tmp_root is not None and tmp_root.exists():
100
+ shutil.rmtree(tmp_root, ignore_errors=True)
codeforge/sandbox/tools.py ADDED
@@ -0,0 +1,85 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import re
5
+ import shutil
6
+ from typing import TYPE_CHECKING
7
+
8
+ from codeforge.sandbox.models import ParsedResult, ToolResult
9
+
10
+ if TYPE_CHECKING:
11
+ from pathlib import Path
12
+
13
+ DEFAULT_TOOLS: tuple[str, ...] = ("ruff", "mypy", "pytest", "imports")
14
+
15
+ _MYPY_ERROR_RE = re.compile(r"Found (\d+) errors?")
16
+
17
+
18
+ def argv_for(name: str, project_dir: Path) -> list[str]:
19
+ """Return the argv for running a tool. Runner sets cwd=project_dir."""
20
+ del project_dir
21
+ if name == "ruff":
22
+ return ["ruff", "check", "--output-format", "json", "."]
23
+ if name == "mypy":
24
+ return ["mypy", "--no-incremental", "--strict", "."]
25
+ if name == "pytest":
26
+ return ["pytest", "-q", "--tb=line", "--no-header"]
27
+ if name == "pip-audit":
28
+ return ["pip-audit", "--format", "json"]
29
+ msg = f"unknown tool: {name}"
30
+ raise ValueError(msg)
31
+
32
+
33
+ def parse(name: str, tool_result: ToolResult) -> ParsedResult:
34
+ if name == "ruff":
35
+ return _parse_ruff(tool_result)
36
+ if name == "mypy":
37
+ return _parse_mypy(tool_result)
38
+ if name == "pytest":
39
+ return _parse_pytest(tool_result)
40
+ if name == "pip-audit":
41
+ return _parse_pip_audit(tool_result)
42
+ return ParsedResult(ok=tool_result.exit_code == 0, count=0, details={})
43
+
44
+
45
+ def _parse_ruff(tr: ToolResult) -> ParsedResult:
46
+ try:
47
+ items = json.loads(tr.stdout or "[]")
48
+ except json.JSONDecodeError:
49
+ return ParsedResult(ok=False, count=0, details={"parse_error": tr.stdout[:500]})
50
+ count = len(items) if isinstance(items, list) else 0
51
+ return ParsedResult(
52
+ ok=count == 0 and tr.exit_code == 0,
53
+ count=count,
54
+ details={"violations": items[:20]},
55
+ )
56
+
57
+
58
+ def _parse_mypy(tr: ToolResult) -> ParsedResult:
59
+ if tr.exit_code == 0 and "Success" in tr.stdout:
60
+ return ParsedResult(ok=True, count=0, details={})
61
+ m = _MYPY_ERROR_RE.search(tr.stdout)
62
+ count = int(m.group(1)) if m else 0
63
+ return ParsedResult(ok=False, count=count, details={"tail": tr.stdout[-500:]})
64
+
65
+
66
+ def _parse_pytest(tr: ToolResult) -> ParsedResult:
67
+ ok = tr.exit_code == 0
68
+ return ParsedResult(ok=ok, count=0 if ok else 1, details={"tail": tr.stdout[-500:]})
69
+
70
+
71
+ def _parse_pip_audit(tr: ToolResult) -> ParsedResult:
72
+ if tr.exit_code == 0:
73
+ return ParsedResult(ok=True, count=0, details={})
74
+ try:
75
+ data = json.loads(tr.stdout or "{}")
76
+ except json.JSONDecodeError:
77
+ data = {}
78
+ count = len(data.get("vulnerabilities", []))
79
+ return ParsedResult(ok=count == 0, count=count, details={"tail": tr.stdout[-500:]})
80
+
81
+
82
+ def is_available(name: str) -> bool:
83
+ if name == "imports":
84
+ return True
85
+ return shutil.which(name) is not None
codeforge/scraper/__init__.py ADDED
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from codeforge.scraper.discovery import SourceRoot, walk_sources
4
+ from codeforge.scraper.parser import ParsedSkill, ParseError, parse_skill
5
+ from codeforge.scraper.pipeline import ScrapeResult, run_scraper, scrape_single_skill
6
+
7
+ __all__ = [
8
+ "ParseError",
9
+ "ParsedSkill",
10
+ "ScrapeResult",
11
+ "SourceRoot",
12
+ "parse_skill",
13
+ "run_scraper",
14
+ "scrape_single_skill",
15
+ "walk_sources",
16
+ ]
codeforge/scraper/chunker.py ADDED
@@ -0,0 +1,108 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from markdown_it import MarkdownIt
4
+ from markdown_it.token import Token
5
+ from pydantic import BaseModel, ConfigDict
6
+
7
+ MIN_CHUNK_CHARS = 80
8
+ MAX_HEADING_LEVEL = 3
9
+
10
+ _md = MarkdownIt()
11
+
12
+
13
+ class SectionChunk(BaseModel):
14
+ model_config = ConfigDict(frozen=True)
15
+ section_path: tuple[str, ...]
16
+ section_body: str
17
+
18
+
19
+ def _segment_tokens(
20
+ tokens: list[Token],
21
+ ) -> list[tuple[int, str, list[str]]]:
22
+ """Walk token stream, returning heading-delimited segments.
23
+
24
+ Each segment is (heading_level, heading_text, body_texts).
25
+ Level 0 is a sentinel for content before the first H1-H3.
26
+ """
27
+ segments: list[tuple[int, str, list[str]]] = []
28
+ current: tuple[int, str, list[str]] | None = None
29
+
30
+ for i, tok in enumerate(tokens):
31
+ if tok.type == "heading_open":
32
+ level = int(tok.tag[1]) # "h2" -> 2
33
+ heading_text = tokens[i + 1].content.strip()
34
+ if level <= MAX_HEADING_LEVEL:
35
+ if current is not None:
36
+ segments.append(current)
37
+ current = (level, heading_text, [])
38
+ else:
39
+ if current is None:
40
+ current = (0, "", [])
41
+ current[2].append(heading_text)
42
+ elif tok.type.endswith("_open"):
43
+ continue
44
+ elif tok.type == "inline":
45
+ if current is None:
46
+ current = (0, "", [])
47
+ current[2].append(tok.content)
48
+
49
+ if current is not None:
50
+ segments.append(current)
51
+ return segments
52
+
53
+
54
+ def chunk_body(body: str) -> list[SectionChunk]:
55
+ """Split markdown body into section chunks by H1-H3 headings."""
56
+ tokens = _md.parse(body)
57
+ segments = _segment_tokens(tokens)
58
+
59
+ if not segments:
60
+ text = body.strip()
61
+ if text:
62
+ return [SectionChunk(section_path=(), section_body=text)]
63
+ return []
64
+
65
+ stack: list[str] = []
66
+ chunks: list[SectionChunk] = []
67
+ for level, title, body_parts in segments:
68
+ if level == 0:
69
+ chunks.append(
70
+ SectionChunk(
71
+ section_path=(),
72
+ section_body="\n\n".join(body_parts).strip(),
73
+ )
74
+ )
75
+ continue
76
+ while len(stack) >= level:
77
+ stack.pop()
78
+ stack.append(title)
79
+ chunks.append(
80
+ SectionChunk(
81
+ section_path=tuple(stack),
82
+ section_body="\n\n".join(body_parts).strip(),
83
+ )
84
+ )
85
+
86
+ return _merge_small(chunks)
87
+
88
+
89
+ def _merge_small(chunks: list[SectionChunk]) -> list[SectionChunk]:
90
+ """Merge chunks smaller than MIN_CHUNK_CHARS into their successor."""
91
+ if not chunks:
92
+ return chunks
93
+ merged: list[SectionChunk] = []
94
+ carry: str = ""
95
+ for c in chunks:
96
+ body = (carry + "\n\n" + c.section_body).strip() if carry else c.section_body
97
+ if len(body) < MIN_CHUNK_CHARS and c is not chunks[-1]:
98
+ carry = body
99
+ continue
100
+ merged.append(SectionChunk(section_path=c.section_path, section_body=body))
101
+ carry = ""
102
+ if carry and merged:
103
+ last = merged[-1]
104
+ merged[-1] = SectionChunk(
105
+ section_path=last.section_path,
106
+ section_body=(last.section_body + "\n\n" + carry).strip(),
107
+ )
108
+ return merged
codeforge/scraper/discovery.py ADDED
@@ -0,0 +1,34 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import glob as glob_mod
4
+ import logging
5
+ import os
6
+ from collections.abc import Iterator
7
+ from pathlib import Path
8
+
9
+ from pydantic import BaseModel, ConfigDict
10
+
11
+ _log = logging.getLogger(__name__)
12
+
13
+
14
+ class SourceRoot(BaseModel):
15
+ model_config = ConfigDict(frozen=True)
16
+ label: str
17
+ glob: str
18
+
19
+
20
+ def walk_sources(sources: list[SourceRoot]) -> Iterator[tuple[Path, SourceRoot]]:
21
+ """Yield (path, source_root) for every readable SKILL.md matched by a glob."""
22
+ for root in sources:
23
+ pattern = os.path.expanduser(root.glob)
24
+ for match in glob_mod.glob(pattern, recursive=True):
25
+ path = Path(match)
26
+ if not path.is_file():
27
+ _log.warning("discovery: skipping non-file %s", path)
28
+ continue
29
+ try:
30
+ path.stat()
31
+ except OSError as e:
32
+ _log.warning("discovery: unreadable %s: %s", path, e)
33
+ continue
34
+ yield path, root
codeforge/scraper/parser.py ADDED
@@ -0,0 +1,32 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+
5
+ import frontmatter # type: ignore[import-untyped]
6
+ import yaml
7
+ from pydantic import BaseModel, ConfigDict
8
+
9
+
10
+ class ParsedSkill(BaseModel):
11
+ model_config = ConfigDict(frozen=True)
12
+ frontmatter: dict[str, object]
13
+ body: str
14
+ mtime: float
15
+
16
+
17
+ class ParseError(Exception):
18
+ """Raised when a SKILL.md cannot be parsed."""
19
+
20
+
21
+ def parse_skill(path: Path) -> ParsedSkill:
22
+ """Parse a SKILL.md file: extract YAML frontmatter and markdown body."""
23
+ try:
24
+ raw = path.read_text(encoding="utf-8")
25
+ except OSError as e:
26
+ raise ParseError(f"unreadable: {path}: {e}") from e
27
+ try:
28
+ post = frontmatter.loads(raw)
29
+ except yaml.YAMLError as e:
30
+ raise ParseError(f"malformed yaml in {path}: {e}") from e
31
+ mtime = path.stat().st_mtime
32
+ return ParsedSkill(frontmatter=dict(post.metadata), body=post.content, mtime=mtime)
codeforge/scraper/pipeline.py ADDED
@@ -0,0 +1,154 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import hashlib
4
+ import logging
5
+ from dataclasses import dataclass
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ from codeforge.scraper.chunker import chunk_body
10
+ from codeforge.scraper.discovery import SourceRoot, walk_sources
11
+ from codeforge.scraper.parser import ParseError, parse_skill
12
+ from codeforge.scraper.tagger import infer_tags
13
+ from codeforge.scraper.writer import write_corpus, write_manifest
14
+
15
+ _log = logging.getLogger(__name__)
16
+
17
+
18
+ @dataclass(frozen=True)
19
+ class ScrapeResult:
20
+ scraped_files: int
21
+ skipped_files: int
22
+ total_nodes: int
23
+ errors: list[dict[str, str]]
24
+ corpus_path: Path
25
+ manifest_path: Path
26
+
27
+
28
+ def _node_id(source_path: str, section_path: tuple[str, ...]) -> str:
29
+ raw = source_path + "#" + "/".join(section_path)
30
+ return hashlib.sha256(raw.encode()).hexdigest()[:16]
31
+
32
+
33
+ def _body_hash(body: str) -> str:
34
+ return hashlib.sha256(body.encode()).hexdigest()
35
+
36
+
37
+ def scrape_single_skill(path: Path) -> list[dict[str, Any]]:
38
+ """Scrape a single SKILL.md file and return a list of node dicts."""
39
+ parsed = parse_skill(path)
40
+ body = parsed.body.strip()
41
+ if not body:
42
+ return []
43
+
44
+ fm = parsed.frontmatter
45
+ name_val = fm.get("name")
46
+ skill_name = str(name_val) if name_val else path.parent.name
47
+ desc_val = fm.get("description", "")
48
+ skill_desc = str(desc_val) if desc_val else ""
49
+ type_val = fm.get("type")
50
+ skill_type: str | None = (
51
+ str(type_val) if type_val in ("flexible", "rigid") else None
52
+ )
53
+ triggers = skill_desc
54
+
55
+ raw_chunks = chunk_body(body)
56
+ if not raw_chunks:
57
+ return []
58
+
59
+ chunks = [
60
+ c.model_copy(update={"section_path": (skill_name,)})
61
+ if c.section_path == ()
62
+ else c
63
+ for c in raw_chunks
64
+ ]
65
+
66
+ nodes: list[dict[str, Any]] = []
67
+ for chunk in chunks:
68
+ tags = list(
69
+ infer_tags(
70
+ skill_name,
71
+ chunk.section_path[-1] if chunk.section_path else "",
72
+ chunk.section_body,
73
+ )
74
+ )
75
+ node: dict[str, Any] = {
76
+ "id": _node_id(str(path), chunk.section_path),
77
+ "skill_name": skill_name,
78
+ "skill_description": skill_desc,
79
+ "skill_type": skill_type,
80
+ "section_path": list(chunk.section_path),
81
+ "section_title": chunk.section_path[-1] if chunk.section_path else "",
82
+ "section_body": chunk.section_body,
83
+ "source_path": str(path),
84
+ "source_root": "",
85
+ "tags": tags,
86
+ "trigger_hints": triggers,
87
+ "mtime": parsed.mtime,
88
+ "body_hash": _body_hash(chunk.section_body),
89
+ "alias_sources": [],
90
+ }
91
+ nodes.append(node)
92
+ return nodes
93
+
94
+
95
+ def _dedupe(nodes: list[dict[str, Any]]) -> list[dict[str, Any]]:
96
+ """Deduplicate nodes by (skill_name, section_path, body_hash)."""
97
+ index: dict[tuple[str, tuple[str, ...], str], dict[str, Any]] = {}
98
+ for n in nodes:
99
+ key = (n["skill_name"], tuple(n["section_path"]), n["body_hash"])
100
+ existing = index.get(key)
101
+ if existing is None:
102
+ index[key] = n
103
+ else:
104
+ aliases = list(existing.get("alias_sources", []))
105
+ aliases.append(n["source_path"])
106
+ existing["alias_sources"] = aliases
107
+ return list(index.values())
108
+
109
+
110
+ def run_scraper(
111
+ *, sources: list[SourceRoot], output: Path
112
+ ) -> ScrapeResult:
113
+ """Full pipeline: discover -> parse -> chunk -> tag -> deduplicate -> write."""
114
+ nodes: list[dict[str, Any]] = []
115
+ errors: list[dict[str, str]] = []
116
+ scraped = 0
117
+ skipped = 0
118
+
119
+ for path, root in walk_sources(sources):
120
+ try:
121
+ file_nodes = scrape_single_skill(path)
122
+ except ParseError as e:
123
+ errors.append({"path": str(path), "stage": "parse", "reason": str(e)})
124
+ skipped += 1
125
+ continue
126
+
127
+ if not file_nodes:
128
+ skipped += 1
129
+ continue
130
+
131
+ for n in file_nodes:
132
+ n["source_root"] = root.label
133
+ nodes.extend(file_nodes)
134
+ scraped += 1
135
+
136
+ deduped = _dedupe(nodes)
137
+ corpus_path = write_corpus(deduped, output)
138
+ manifest_path = write_manifest(
139
+ corpus_path=corpus_path,
140
+ sources=[{"label": s.label, "glob": s.glob} for s in sources],
141
+ scraped_files=scraped,
142
+ skipped_files=skipped,
143
+ total_nodes=len(deduped),
144
+ errors=errors,
145
+ )
146
+
147
+ return ScrapeResult(
148
+ scraped_files=scraped,
149
+ skipped_files=skipped,
150
+ total_nodes=len(deduped),
151
+ errors=errors,
152
+ corpus_path=corpus_path,
153
+ manifest_path=manifest_path,
154
+ )
codeforge/scraper/tagger.py ADDED
@@ -0,0 +1,62 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import re
4
+
5
+ DOMAIN_RULES: list[tuple[str, list[str]]] = [
6
+ ("python", ["python", "pytest", "pip", "pydantic"]),
7
+ ("javascript", ["javascript", "typescript", "react", "next.js", "node"]),
8
+ ("go", ["golang", "go"]),
9
+ ("kotlin", ["kotlin", "gradle"]),
10
+ ("security", ["security", "auth", "secret", "owasp", "injection"]),
11
+ ("frontend", ["frontend", "ui", "ux", "tailwind", "css"]),
12
+ ("api", ["api", "endpoint", "rest", "graphql", "openapi"]),
13
+ ("backend", ["backend", "fastapi", "django", "spring"]),
14
+ ("data", ["pandas", "numpy", "clickhouse", "postgres", "sql"]),
15
+ ("mcp", ["mcp", "model context protocol"]),
16
+ ("devops", ["docker", "kubernetes", "ci/cd", "deploy"]),
17
+ ]
18
+
19
+ PHASE_RULES: list[tuple[str, list[str]]] = [
20
+ ("plan", ["plan", "design", "architecture", "brainstorm"]),
21
+ ("build", ["build", "implement", "feature", "write"]),
22
+ ("test", ["test", "pytest", "coverage", "tdd"]),
23
+ ("review", ["review", "critic", "checklist", "audit"]),
24
+ ("deploy", ["deploy", "release", "ship", "publish"]),
25
+ ("debug", ["debug", "fix", "troubleshoot", "bug"]),
26
+ ("docs", ["docs", "documentation", "readme"]),
27
+ ]
28
+
29
+ _TOKEN_RE = re.compile(r"[a-z0-9]+(?:\.[a-z0-9]+)*")
30
+
31
+
32
+ def _tokenize(text: str) -> frozenset[str]:
33
+ """Split lowercased text into alphanumeric tokens."""
34
+ return frozenset(_TOKEN_RE.findall(text.lower()))
35
+
36
+
37
+ def _matches(keyword: str, tokens: frozenset[str], joined_lower: str) -> bool:
38
+ if " " in keyword:
39
+ return keyword in joined_lower
40
+ return keyword in tokens
41
+
42
+
43
+ def infer_tags(skill_name: str, section_title: str, body: str) -> list[str]:
44
+ """Infer domain and phase tags from skill name, section title, and body."""
45
+ joined_lower = " ".join(
46
+ p.lower() for p in (skill_name, section_title, body) if p
47
+ )
48
+ tokens = _tokenize(joined_lower)
49
+ tags: list[str] = []
50
+
51
+ domain = "general"
52
+ for name, keywords in DOMAIN_RULES:
53
+ if any(_matches(k, tokens, joined_lower) for k in keywords):
54
+ domain = name
55
+ break
56
+ tags.append(f"domain:{domain}")
57
+
58
+ for name, keywords in PHASE_RULES:
59
+ if any(_matches(k, tokens, joined_lower) for k in keywords):
60
+ tags.append(f"phase:{name}")
61
+
62
+ return tags
codeforge/scraper/writer.py ADDED
@@ -0,0 +1,52 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import hashlib
4
+ import json
5
+ from collections.abc import Iterable
6
+ from datetime import UTC, datetime
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+
11
+ def write_corpus(nodes: Iterable[dict[str, Any]], out_path: Path) -> Path:
12
+ """Write nodes as sorted JSONL."""
13
+ out_path.parent.mkdir(parents=True, exist_ok=True)
14
+ sorted_nodes = sorted(nodes, key=lambda n: str(n["id"]))
15
+ with out_path.open("w", encoding="utf-8") as f:
16
+ for node in sorted_nodes:
17
+ f.write(json.dumps(node, default=str))
18
+ f.write("\n")
19
+ return out_path
20
+
21
+
22
+ def compute_corpus_sha256(corpus_path: Path) -> str:
23
+ """SHA256 hex digest of the corpus file."""
24
+ return hashlib.sha256(corpus_path.read_bytes()).hexdigest()
25
+
26
+
27
+ def write_manifest(
28
+ *,
29
+ corpus_path: Path,
30
+ sources: list[dict[str, str]],
31
+ scraped_files: int,
32
+ skipped_files: int,
33
+ total_nodes: int,
34
+ errors: list[dict[str, str]],
35
+ ) -> Path:
36
+ """Write a manifest JSON alongside the corpus."""
37
+ digest = compute_corpus_sha256(corpus_path)
38
+ manifest = {
39
+ "generated_at": datetime.now(UTC).isoformat(timespec="seconds"),
40
+ "sources": sources,
41
+ "scraped_files": scraped_files,
42
+ "skipped_files": skipped_files,
43
+ "total_nodes": total_nodes,
44
+ "errors": errors,
45
+ "corpus_sha256": digest,
46
+ }
47
+ manifest_path = corpus_path.with_suffix(".manifest.json")
48
+ manifest_path.parent.mkdir(parents=True, exist_ok=True)
49
+ manifest_path.write_text(
50
+ json.dumps(manifest, indent=2), encoding="utf-8"
51
+ )
52
+ return manifest_path
codeforge/shaping.py ADDED
@@ -0,0 +1,36 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import re
4
+
5
+ # Match explicit citation comments: # cited: skill-name or # ref: skill-name
6
+ _CITATION_RE = re.compile(r"#\s*(?:cited|ref|source|from):\s*(\S+)", re.IGNORECASE)
7
+
8
+
9
+ def citation_shaping_bonus(
10
+ *,
11
+ submit_files: dict[str, str],
12
+ prior_citations: list[dict[str, object]],
13
+ prior_cluster_hits: list[str],
14
+ ) -> float:
15
+ """Retroactive shaping bonus for prior queries whose cited skills appear in submitted code.
16
+
17
+ +0.01 per cited skill name found as an explicit citation comment in the code,
18
+ max 0.05. Only fires on submit with reward > 0. See SYSTEM_DESIGN §4.8.4.
19
+
20
+ Uses explicit comment matching (``# cited: skill-name``) instead of substring
21
+ matching to prevent common-word skill names from trivially matching any code.
22
+ """
23
+ if not prior_citations:
24
+ return 0.0
25
+
26
+ cited_skills: set[str] = set()
27
+ for c in prior_citations:
28
+ sn = c.get("skill_name")
29
+ if isinstance(sn, str):
30
+ cited_skills.add(sn.lower())
31
+
32
+ code_text = "\n".join(submit_files.values())
33
+ code_citations = {m.group(1).lower() for m in _CITATION_RE.finditer(code_text)}
34
+
35
+ overlap = len(cited_skills & code_citations)
36
+ return min(overlap * 0.01, 0.05)
codeforge/tasks.py ADDED
@@ -0,0 +1,137 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+
5
+
6
+ @dataclass(frozen=True)
7
+ class Task:
8
+ task_id: str
9
+ task_level: str
10
+ brief: str
11
+ initial_files: dict[str, str]
12
+ target_score: float
13
+ max_budget: int
14
+ tools: tuple[str, ...]
15
+ hidden_tests: dict[str, str] = () # type: ignore[assignment]
16
+ """Hidden correctness tests injected by the environment during grading.
17
+
18
+ The agent never sees these. They are written into the sandbox temp dir
19
+ alongside the agent's submitted files so pytest runs them automatically.
20
+ This prevents "clean garbage" exploits where syntactically valid but
21
+ semantically wrong code scores perfectly.
22
+ """
23
+
24
+
25
+ # -- Hidden test suites (agent never sees these) ----------------------------
26
+
27
+ _HIDDEN_EASY = {
28
+ "test_hidden_greet.py": (
29
+ "from __future__ import annotations\n"
30
+ "from main import greet\n\n"
31
+ "def test_greet_alice() -> None:\n"
32
+ ' assert greet("Alice") == "Hello, Alice!"\n\n'
33
+ "def test_greet_bob() -> None:\n"
34
+ ' assert greet("Bob") == "Hello, Bob!"\n\n'
35
+ "def test_greet_empty() -> None:\n"
36
+ ' assert greet("") == "Hello, !"\n'
37
+ ),
38
+ }
39
+
40
+ _HIDDEN_MEDIUM = {
41
+ "test_hidden_greet.py": (
42
+ "from __future__ import annotations\n"
43
+ "import pytest\n"
44
+ "from main import greet\n\n"
45
+ "def test_greet_alice() -> None:\n"
46
+ ' assert greet("Alice") == "Hello, Alice!"\n\n'
47
+ "def test_greet_none_raises() -> None:\n"
48
+ " with pytest.raises(ValueError):\n"
49
+ " greet(None) # type: ignore[arg-type]\n\n"
50
+ "def test_greet_returns_str() -> None:\n"
51
+ ' assert isinstance(greet("X"), str)\n'
52
+ ),
53
+ }
54
+
55
+ _HIDDEN_HARD = {
56
+ "test_hidden_core.py": (
57
+ "from __future__ import annotations\n"
58
+ "import pytest\n"
59
+ "from core import greet\n\n"
60
+ "def test_greet_alice() -> None:\n"
61
+ ' assert greet("Alice") == "Hello, Alice!"\n\n'
62
+ "def test_greet_bob() -> None:\n"
63
+ ' assert greet("Bob") == "Hello, Bob!"\n\n'
64
+ "def test_greet_returns_str() -> None:\n"
65
+ ' assert isinstance(greet("X"), str)\n\n'
66
+ "def test_greet_empty() -> None:\n"
67
+ ' assert greet("") == "Hello, !"\n'
68
+ ),
69
+ }
70
+
71
+
72
+ TASKS: tuple[Task, ...] = (
73
+ Task(
74
+ task_id="greet_single_file",
75
+ task_level="easy",
76
+ brief=(
77
+ "Implement `greet(name)` in `main.py` so that `greet(\"Alice\")` returns "
78
+ '`"Hello, Alice!"`. Use type hints. Keep the module under 15 lines.'
79
+ ),
80
+ initial_files={"main.py": "def greet(name):\n pass\n"},
81
+ target_score=0.90,
82
+ max_budget=4,
83
+ tools=("ruff", "imports", "mypy", "pytest"),
84
+ hidden_tests=_HIDDEN_EASY,
85
+ ),
86
+ Task(
87
+ task_id="greet_with_tests",
88
+ task_level="medium",
89
+ brief=(
90
+ "Extend `main.py` so that `greet(None)` raises `ValueError`, "
91
+ "and add a `test_main.py` with pytest assertions. Keep `ruff` and "
92
+ "`mypy --strict` clean."
93
+ ),
94
+ initial_files={
95
+ "main.py": (
96
+ "from __future__ import annotations\n\n\n"
97
+ "def greet(name: str) -> str:\n"
98
+ ' return f"Hello, {name}!"\n'
99
+ ),
100
+ "test_main.py": "",
101
+ },
102
+ target_score=0.80,
103
+ max_budget=6,
104
+ tools=("ruff", "imports", "mypy", "pytest"),
105
+ hidden_tests=_HIDDEN_MEDIUM,
106
+ ),
107
+ Task(
108
+ task_id="multi_file_module",
109
+ task_level="hard",
110
+ brief=(
111
+ "Split into three files: `main.py` (entry), `core.py` (the greet "
112
+ "function), `test_core.py` (tests). Every function must be type-hinted. "
113
+ "All tests pass. `mypy --strict` clean."
114
+ ),
115
+ initial_files={
116
+ "main.py": (
117
+ "from __future__ import annotations\n\nfrom core import greet\n\n\n"
118
+ 'if __name__ == "__main__":\n'
119
+ ' print(greet("World"))\n'
120
+ ),
121
+ "core.py": "",
122
+ "test_core.py": "",
123
+ },
124
+ target_score=0.70,
125
+ max_budget=10,
126
+ tools=("ruff", "imports", "mypy", "pytest"),
127
+ hidden_tests=_HIDDEN_HARD,
128
+ ),
129
+ )
130
+
131
+
132
+ def get_task(task_level: str) -> Task:
133
+ for t in TASKS:
134
+ if t.task_level == task_level:
135
+ return t
136
+ msg = f"unknown task_level: {task_level!r} (expected easy|medium|hard)"
137
+ raise ValueError(msg)
dataset/mbpp.jsonl ADDED
The diff for this file is too large to render. See raw diff
 
requirements.txt ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ fastapi
2
+ uvicorn[standard]
3
+ unsloth
4
+ trl>=0.14.0
5
+ datasets
6
+ peft
7
+ transformers
8
+ accelerate
9
+ pytest
10
+ ruff
11
+ mypy