Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,8 @@ evolution-kernel --config evolution.yml --repo /path/to/project --ledger /tmp/le

## See it in action

> 📋 **Illustrative scenario.** The numbers below describe what a complete, well-targeted overnight run on the GSM8K case looks like — they are a design narrative, not a checked-in artifact in this repo. For runs anyone can reproduce today, see [`evidence/`](evidence/) and [`examples/demo_target`](examples/demo_target).

### $34. One night. An 8B model that runs on a MacBook — from 51.8% to 96.2% on elementary math. Zero weight changes.

> Qwen3-8B-Instruct is a general-purpose model with no math-specific training. Its weights are frozen throughout. Evolution Kernel evolves only the solver harness — prompt strategies, tools, and sampling logic. After one overnight run, the same model sits 2.8 points behind GPT-5.5. That means every child can have a free, local, always-on, privacy-safe math tutor.
Expand Down Expand Up @@ -248,6 +250,7 @@ flowchart LR
| Goal evaluator — stops when mission is "won" | ✅ |
| k-branch parallel exploration (FunSearch / AlphaEvolve style) | ✅ |
| Process sandbox via firejail — executor cannot write outside its worktree | ✅ |
| Remote observer — HTTP evidence source for live dashboards / eval endpoints | ✅ |

---

Expand All @@ -266,6 +269,11 @@ evidence_sources:
command: "python3 scripts/run_gsm8k.py --model qwen3-8b-instruct --sample 100 --json"
- type: file # file contents go into observation.json
path: "metrics.json"
- type: http # GET a live endpoint; status, headers and body recorded
url: "https://evals.example.com/run/latest"
headers:
Accept: application/json
timeout: 10 # seconds (default 10)
Comment on lines +272 to +276

# Only files under these paths may be changed
mutation_scope:
Expand Down
8 changes: 8 additions & 0 deletions README.zh.md
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,8 @@ evolution-kernel --config evolution.yml --repo /path/to/project --ledger /tmp/le

## 看它实际运行

> 📋 **示意场景。** 下面的数字描述了"一个完整、目标明确的隔夜跑"是什么样——这是 GSM8K 案例的设计叙事,不是仓库里 checked-in 的真实运行记录。今天就能复现的真实跑,请参考 [`evidence/`](evidence/) 和 [`examples/demo_target`](examples/demo_target)。

### $34,一晚上,一个能在 MacBook 上跑的 8B 模型——小学数学应用题正确率 96.2%,和 GPT-5.5 基本同档。模型权重一字节未动。

> Qwen3-8B-Instruct 是一个通用模型,没有专门的数学训练。权重全程冻结。Evolution Kernel 只进化 solver harness——提示策略、工具调用和采样逻辑。一个隔夜跑完,同一个模型只落后 GPT-5.5 2.8 个百分点。这意味着每个孩子都能拥有一个免费、本地、随时在线、完全保护隐私的数学辅导老师。
Expand Down Expand Up @@ -248,6 +250,7 @@ flowchart LR
| 目标评估器——当 mission 完成时自动停止 | ✅ |
| k 路并行探索(FunSearch / AlphaEvolve 模式) | ✅ |
| 进程级沙箱(firejail)——执行器无法写出 worktree 之外的任何文件 | ✅ |
| 远程观察者——HTTP 证据源,把线上 dashboard / eval endpoint 拉进 observation.json | ✅ |

---

Expand All @@ -266,6 +269,11 @@ evidence_sources:
command: "python3 scripts/run_gsm8k.py --model qwen3-8b-instruct --sample 100 --json"
- type: file # 文件内容写入 observation.json
path: "metrics.json"
- type: http # GET 一个线上接口;status / headers / body 都进 observation
url: "https://evals.example.com/run/latest"
headers:
Accept: application/json
timeout: 10 # 秒(默认 10)

# 只有这些路径下的文件允许被修改
mutation_scope:
Expand Down
62 changes: 60 additions & 2 deletions evolution_kernel/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,12 @@
path: "./metrics.json"
- type: shell
command: "bash ./scripts/status.sh"
- type: http
url: "http://localhost:8000/status"
method: GET # optional, default GET
headers: # optional
Accept: application/json
timeout: 10 # optional seconds, default 10

mutation_scope:
allowed_paths:
Expand Down Expand Up @@ -58,9 +64,14 @@ class ConfigError(ValueError):

@dataclass(frozen=True)
class EvidenceSource:
type: str # "file" or "shell"
type: str # "file" | "shell" | "http"
path: str | None = None
command: str | None = None
# HTTP-only fields
url: str | None = None
method: str = "GET"
headers: tuple[tuple[str, str], ...] = ()
timeout: float = 10.0


@dataclass(frozen=True)
Expand Down Expand Up @@ -208,9 +219,56 @@ def _parse_evidence_sources(value: Any) -> Sequence[EvidenceSource]:
f"evidence_sources[{index}] type=shell requires a non-empty `command`"
)
sources.append(EvidenceSource(type="shell", command=command.strip()))
elif kind == "http":
url = item.get("url")
if not isinstance(url, str) or not url.strip():
raise ConfigError(
f"evidence_sources[{index}] type=http requires a non-empty `url`"
)
method_raw = item.get("method", "GET")
if not isinstance(method_raw, str) or not method_raw.strip():
raise ConfigError(
f"evidence_sources[{index}].method must be a non-empty string"
)
headers_raw = item.get("headers", {})
if not isinstance(headers_raw, Mapping):
raise ConfigError(
f"evidence_sources[{index}].headers must be a mapping"
)
headers: list[tuple[str, str]] = []
for hk, hv in headers_raw.items():
if not isinstance(hk, str) or not hk.strip():
raise ConfigError(
f"evidence_sources[{index}].headers keys must be non-empty strings"
)
if not isinstance(hv, (str, int, float)):
raise ConfigError(
f"evidence_sources[{index}].headers[{hk!r}] must be str | int | float"
)
headers.append((hk.strip(), str(hv)))
timeout_raw = item.get("timeout", 10.0)
try:
timeout = float(timeout_raw)
except (TypeError, ValueError):
raise ConfigError(
f"evidence_sources[{index}].timeout must be a number, got {timeout_raw!r}"
)
if timeout <= 0:
raise ConfigError(
f"evidence_sources[{index}].timeout must be > 0"
)
sources.append(
EvidenceSource(
type="http",
url=url.strip(),
method=method_raw.strip().upper(),
headers=tuple(headers),
timeout=timeout,
)
)
else:
raise ConfigError(
f"evidence_sources[{index}].type must be 'file' or 'shell', got {kind!r}"
f"evidence_sources[{index}].type must be 'file', 'shell', or 'http', got {kind!r}"
)
return sources

Expand Down
57 changes: 57 additions & 0 deletions evolution_kernel/observer.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,20 +8,24 @@

import json
import subprocess
import urllib.error
import urllib.request
from pathlib import Path
from typing import Any, Mapping, Sequence

from .config import EvidenceSource

DEFAULT_FILE_LIMIT = 64 * 1024 # 64 KiB
DEFAULT_SHELL_TIMEOUT = 30 # seconds
DEFAULT_HTTP_LIMIT = 64 * 1024 # 64 KiB body cap


def collect_observation(
sources: Sequence[EvidenceSource],
cwd: Path | str,
file_limit: int = DEFAULT_FILE_LIMIT,
shell_timeout: int = DEFAULT_SHELL_TIMEOUT,
http_limit: int = DEFAULT_HTTP_LIMIT,
) -> Mapping[str, Any]:
"""Run each evidence source and return a structured bundle."""
base = Path(cwd).resolve()
Expand All @@ -31,6 +35,8 @@ def collect_observation(
collected.append(_collect_file(source.path or "", base, file_limit))
elif source.type == "shell":
collected.append(_collect_shell(source.command or "", base, shell_timeout))
elif source.type == "http":
collected.append(_collect_http(source, http_limit))
else:
collected.append({"type": source.type, "error": "unknown source type"})
return {"cwd": str(base), "sources": collected}
Expand Down Expand Up @@ -73,6 +79,57 @@ def _collect_file(rel_path: str, cwd: Path, limit: int) -> dict[str, Any]:
return record


def _collect_http(source: EvidenceSource, limit: int) -> dict[str, Any]:
"""Fetch one HTTP endpoint into a structured record.

The observer stays a thin collector: it captures status, headers, and a
byte-capped body, plus an ``error`` string on failure. Retries, auth
flows, and content parsing belong upstream (planner / role scripts).
"""
url = source.url or ""
method = (source.method or "GET").upper()
record: dict[str, Any] = {"type": "http", "url": url, "method": method}
if not url.strip():
record["error"] = "empty url"
return record
request = urllib.request.Request(url, method=method)
for header_name, header_value in source.headers:
request.add_header(header_name, header_value)
try:
with urllib.request.urlopen(request, timeout=source.timeout) as response:
status = int(getattr(response, "status", response.getcode() or 0))
headers_obj = response.headers
body = response.read(limit + 1)
except urllib.error.HTTPError as exc:
# Non-2xx responses still carry a body — record it like a success
# except for the status code so the planner can react to 4xx/5xx.
try:
body = exc.read(limit + 1)
except Exception:
body = b""
headers_obj = exc.headers
status = int(exc.code)
except urllib.error.URLError as exc:
record["error"] = f"URLError: {exc.reason!r}"
return record
except (TimeoutError, OSError) as exc:
record["error"] = f"{type(exc).__name__}: {exc}"
return record

if len(body) > limit:
record["truncated"] = True
body = body[:limit]
record["status"] = status
record["bytes"] = len(body)
record["body"] = body.decode("utf-8", errors="replace")
# Sort headers so the observation bundle is stable for ledger diffs.
record["headers"] = sorted(
((str(k), str(v)) for k, v in headers_obj.items()),
key=lambda kv: (kv[0].lower(), kv[1]),
)
return record


def _collect_shell(command: str, cwd: Path, timeout: int) -> dict[str, Any]:
record: dict[str, Any] = {"type": "shell", "command": command}
if not command.strip():
Expand Down
Loading
Loading