647 lines
24 KiB
Python
647 lines
24 KiB
Python
from __future__ import annotations
|
|
|
|
import json
|
|
import subprocess
|
|
from dataclasses import asdict, dataclass
|
|
from datetime import datetime, timezone
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
from .llm_backend import DEFAULT_OLLAMA_HOST, DEFAULT_OLLAMA_MODEL
|
|
from .task_packet import TaskPacket
|
|
from .worker_api import create_worker, run_worker
|
|
from .autoresearch_runner import (
|
|
DEFAULT_RESULTS_TSV,
|
|
ExperimentMetrics,
|
|
append_results_row,
|
|
best_recorded_bpb,
|
|
resolve_autoresearch_root,
|
|
run_experiment,
|
|
setup_autoresearch,
|
|
short_head_commit,
|
|
)
|
|
|
|
DEFAULT_AUTORESEARCH_TIMEOUT_SECONDS = 600
|
|
DEFAULT_STATE_FILE = '.autoresearch_state.json'
|
|
DEFAULT_MEMORY_FILE = 'experiment_memory.jsonl'
|
|
AUTORESEARCH_BRANCH_PREFIX = 'autoresearch/'
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class AutoresearchExperimentPacket:
|
|
objective: str
|
|
description: str
|
|
train_command: str = 'uv run train.py > run.log 2>&1'
|
|
timeout_seconds: int = DEFAULT_AUTORESEARCH_TIMEOUT_SECONDS
|
|
log_path: str = 'run.log'
|
|
results_tsv: str = DEFAULT_RESULTS_TSV
|
|
syntax_check_command: str = 'python3 -m py_compile train.py'
|
|
|
|
def to_dict(self) -> dict[str, Any]:
|
|
return asdict(self)
|
|
|
|
@classmethod
|
|
def from_dict(cls, payload: dict[str, Any]) -> 'AutoresearchExperimentPacket':
|
|
return cls(
|
|
objective=str(payload.get('objective', '')),
|
|
description=str(payload.get('description', '')),
|
|
train_command=str(payload.get('train_command', 'uv run train.py > run.log 2>&1')),
|
|
timeout_seconds=int(payload.get('timeout_seconds', DEFAULT_AUTORESEARCH_TIMEOUT_SECONDS)),
|
|
log_path=str(payload.get('log_path', 'run.log')),
|
|
results_tsv=str(payload.get('results_tsv', DEFAULT_RESULTS_TSV)),
|
|
syntax_check_command=str(payload.get('syntax_check_command', 'python3 -m py_compile train.py')),
|
|
)
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class PendingExperiment:
|
|
commit: str
|
|
base_commit: str
|
|
description: str
|
|
packet: dict[str, Any]
|
|
worker: dict[str, Any]
|
|
experiment: dict[str, Any]
|
|
recommended_status: str
|
|
results_tsv: str
|
|
log_path: str
|
|
created_at: str
|
|
|
|
def to_dict(self) -> dict[str, Any]:
|
|
return asdict(self)
|
|
|
|
@classmethod
|
|
def from_dict(cls, payload: dict[str, Any]) -> 'PendingExperiment':
|
|
return cls(
|
|
commit=payload['commit'],
|
|
base_commit=payload['base_commit'],
|
|
description=payload['description'],
|
|
packet=payload['packet'],
|
|
worker=payload['worker'],
|
|
experiment=payload['experiment'],
|
|
recommended_status=payload['recommended_status'],
|
|
results_tsv=payload['results_tsv'],
|
|
log_path=payload['log_path'],
|
|
created_at=payload['created_at'],
|
|
)
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class AutoresearchState:
|
|
root: str
|
|
branch: str
|
|
baseline_commit: str | None = None
|
|
baseline_bpb: float | None = None
|
|
best_commit: str | None = None
|
|
best_bpb: float | None = None
|
|
pending_experiment: dict[str, Any] | None = None
|
|
last_decision: str | None = None
|
|
updated_at: str = ''
|
|
|
|
def to_dict(self) -> dict[str, Any]:
|
|
return asdict(self)
|
|
|
|
@classmethod
|
|
def from_dict(cls, payload: dict[str, Any]) -> 'AutoresearchState':
|
|
return cls(
|
|
root=payload['root'],
|
|
branch=payload['branch'],
|
|
baseline_commit=payload.get('baseline_commit'),
|
|
baseline_bpb=payload.get('baseline_bpb'),
|
|
best_commit=payload.get('best_commit'),
|
|
best_bpb=payload.get('best_bpb'),
|
|
pending_experiment=payload.get('pending_experiment'),
|
|
last_decision=payload.get('last_decision'),
|
|
updated_at=payload.get('updated_at', ''),
|
|
)
|
|
|
|
|
|
def load_autoresearch_packet(path: str | Path) -> AutoresearchExperimentPacket:
|
|
payload = json.loads(Path(path).read_text(encoding='utf-8'))
|
|
if not isinstance(payload, dict):
|
|
raise ValueError('autoresearch packet must be a JSON object')
|
|
packet = AutoresearchExperimentPacket.from_dict(payload)
|
|
validate_autoresearch_packet(packet)
|
|
return packet
|
|
|
|
|
|
def validate_autoresearch_packet(packet: AutoresearchExperimentPacket) -> None:
|
|
errors: list[str] = []
|
|
if not packet.objective.strip():
|
|
errors.append('objective must not be empty')
|
|
if not packet.description.strip():
|
|
errors.append('description must not be empty')
|
|
if packet.timeout_seconds < 1:
|
|
errors.append('timeout_seconds must be >= 1')
|
|
if not packet.train_command.strip():
|
|
errors.append('train_command must not be empty')
|
|
if errors:
|
|
raise ValueError('; '.join(errors))
|
|
|
|
|
|
def render_autoresearch_task_packet(packet: AutoresearchExperimentPacket, repo_root: Path) -> TaskPacket:
|
|
return TaskPacket(
|
|
objective=packet.objective,
|
|
scope=(
|
|
'Modify only train.py in the autoresearch repo. You may read program.md, README.md, and train.py for context, '
|
|
'but train.py is the only file you may change. Editing any other path is failure. '
|
|
'Do not edit prepare.py, pyproject.toml, or dependencies. Do not run the full training experiment yourself; '
|
|
'the manager will run it after your code change. Use syntax and git inspection tools when helpful.'
|
|
),
|
|
repo=str(repo_root),
|
|
branch_policy='Stay on the current branch. Do not create or switch branches.',
|
|
acceptance_tests=(packet.syntax_check_command,),
|
|
commit_policy='Do not commit. The manager records and evaluates the experiment after your edit.',
|
|
reporting_contract=(
|
|
'Return a concise summary of the train.py change, intended effect on val_bpb, and any blockers.'
|
|
),
|
|
escalation_policy='Stop if a required file outside train.py would need modification or if syntax cannot be preserved.',
|
|
)
|
|
|
|
|
|
def run_autoresearch_packet(
|
|
packet_source: AutoresearchExperimentPacket | str | Path,
|
|
root: str | Path | None = None,
|
|
model: str = DEFAULT_OLLAMA_MODEL,
|
|
host: str = DEFAULT_OLLAMA_HOST,
|
|
trace: bool = False,
|
|
) -> dict[str, Any]:
|
|
repo_root = resolve_autoresearch_root(root)
|
|
setup = setup_autoresearch(repo_root, initialize_results=True)
|
|
packet = packet_source if isinstance(packet_source, AutoresearchExperimentPacket) else load_autoresearch_packet(packet_source)
|
|
state = load_autoresearch_state(repo_root)
|
|
base_commit = short_head_commit(repo_root)
|
|
|
|
worker = create_worker(root=repo_root, model=model, host=host)
|
|
worker_run = run_worker(
|
|
worker.worker_id,
|
|
render_autoresearch_task_packet(packet, repo_root),
|
|
trace=trace,
|
|
)
|
|
|
|
changed_files = _project_changed_files(worker_run.last_result.get('changed_files', []) if worker_run.last_result else [])
|
|
if 'train.py' not in changed_files:
|
|
result = {
|
|
'setup': setup.to_dict(),
|
|
'state': state.to_dict(),
|
|
'worker': worker_run.to_dict(),
|
|
'experiment': None,
|
|
'results_tsv': str((repo_root / packet.results_tsv).resolve()),
|
|
'recommended_status': 'discard',
|
|
'error': 'worker did not modify train.py',
|
|
}
|
|
append_memory_event(repo_root, 'run_skipped', result)
|
|
return result
|
|
|
|
commit = _commit_train_change(repo_root, packet.description)
|
|
experiment = run_experiment(
|
|
root=repo_root,
|
|
command=packet.train_command,
|
|
log_path=packet.log_path,
|
|
timeout_seconds=packet.timeout_seconds,
|
|
)
|
|
previous_best = state.best_bpb if state.best_bpb is not None else best_recorded_bpb(repo_root, packet.results_tsv)
|
|
recommended_status = _recommend_status(experiment, previous_best)
|
|
pending = PendingExperiment(
|
|
commit=commit,
|
|
base_commit=base_commit,
|
|
description=packet.description,
|
|
packet=packet.to_dict(),
|
|
worker=worker_run.to_dict(),
|
|
experiment=experiment.to_dict(),
|
|
recommended_status=recommended_status,
|
|
results_tsv=packet.results_tsv,
|
|
log_path=packet.log_path,
|
|
created_at=_iso_now(),
|
|
)
|
|
updated_state = replace_autoresearch_state(
|
|
state,
|
|
branch=_current_branch(repo_root),
|
|
pending_experiment=pending.to_dict(),
|
|
updated_at=_iso_now(),
|
|
)
|
|
save_autoresearch_state(updated_state, repo_root)
|
|
|
|
result = {
|
|
'setup': setup.to_dict(),
|
|
'state': updated_state.to_dict(),
|
|
'worker': worker_run.to_dict(),
|
|
'experiment': experiment.to_dict(),
|
|
'results_tsv': str((repo_root / packet.results_tsv).resolve()),
|
|
'commit': commit,
|
|
'recommended_status': recommended_status,
|
|
'description': packet.description,
|
|
}
|
|
append_memory_event(repo_root, 'candidate_run', result)
|
|
return result
|
|
|
|
|
|
def ensure_autoresearch_baseline(
|
|
root: str | Path | None = None,
|
|
command: str = 'uv run train.py > run.log 2>&1',
|
|
log_path: str = 'run.log',
|
|
results_tsv: str = DEFAULT_RESULTS_TSV,
|
|
timeout_seconds: int = DEFAULT_AUTORESEARCH_TIMEOUT_SECONDS,
|
|
) -> dict[str, Any]:
|
|
repo_root = resolve_autoresearch_root(root)
|
|
setup = setup_autoresearch(repo_root, initialize_results=True)
|
|
state = load_autoresearch_state(repo_root)
|
|
existing_best = state.best_bpb if state.best_bpb is not None else best_recorded_bpb(repo_root, results_tsv)
|
|
if existing_best is not None:
|
|
if state.best_bpb is None:
|
|
state = replace_autoresearch_state(
|
|
state,
|
|
branch=_current_branch(repo_root),
|
|
best_bpb=existing_best,
|
|
updated_at=_iso_now(),
|
|
)
|
|
save_autoresearch_state(state, repo_root)
|
|
result = {
|
|
'setup': setup.to_dict(),
|
|
'state': state.to_dict(),
|
|
'baseline_created': False,
|
|
'reason': 'baseline already recorded',
|
|
}
|
|
append_memory_event(repo_root, 'baseline_skipped', result)
|
|
return result
|
|
|
|
commit = short_head_commit(repo_root)
|
|
experiment = run_experiment(repo_root, command=command, log_path=log_path, timeout_seconds=timeout_seconds)
|
|
if not experiment.success or experiment.val_bpb is None:
|
|
result = {
|
|
'setup': setup.to_dict(),
|
|
'state': state.to_dict(),
|
|
'baseline_created': False,
|
|
'experiment': experiment.to_dict(),
|
|
'error': 'baseline experiment failed',
|
|
}
|
|
append_memory_event(repo_root, 'baseline_failed', result)
|
|
return result
|
|
|
|
append_results_row(repo_root, commit=commit, metrics=experiment, status='keep', description='baseline', results_path=results_tsv)
|
|
updated_state = replace_autoresearch_state(
|
|
state,
|
|
branch=_current_branch(repo_root),
|
|
baseline_commit=commit,
|
|
baseline_bpb=experiment.val_bpb,
|
|
best_commit=commit,
|
|
best_bpb=experiment.val_bpb,
|
|
pending_experiment=None,
|
|
last_decision='baseline',
|
|
updated_at=_iso_now(),
|
|
)
|
|
save_autoresearch_state(updated_state, repo_root)
|
|
result = {
|
|
'setup': setup.to_dict(),
|
|
'state': updated_state.to_dict(),
|
|
'baseline_created': True,
|
|
'experiment': experiment.to_dict(),
|
|
'commit': commit,
|
|
}
|
|
append_memory_event(repo_root, 'baseline_created', result)
|
|
return result
|
|
|
|
|
|
def keep_autoresearch_candidate(root: str | Path | None = None) -> dict[str, Any]:
|
|
repo_root = resolve_autoresearch_root(root)
|
|
state = load_autoresearch_state(repo_root)
|
|
pending = _require_pending_experiment(state)
|
|
metrics = ExperimentMetrics(**pending.experiment)
|
|
append_results_row(
|
|
repo_root,
|
|
commit=pending.commit,
|
|
metrics=metrics,
|
|
status='keep',
|
|
description=pending.description,
|
|
results_path=pending.results_tsv,
|
|
)
|
|
improved = metrics.val_bpb is not None and (state.best_bpb is None or metrics.val_bpb < state.best_bpb)
|
|
updated_state = replace_autoresearch_state(
|
|
state,
|
|
branch=_current_branch(repo_root),
|
|
best_commit=pending.commit if improved else state.best_commit,
|
|
best_bpb=metrics.val_bpb if improved else state.best_bpb,
|
|
pending_experiment=None,
|
|
last_decision='keep',
|
|
updated_at=_iso_now(),
|
|
)
|
|
save_autoresearch_state(updated_state, repo_root)
|
|
result = {
|
|
'decision': 'keep',
|
|
'commit': pending.commit,
|
|
'improved_best': improved,
|
|
'state': updated_state.to_dict(),
|
|
'experiment': pending.experiment,
|
|
}
|
|
append_memory_event(repo_root, 'keep', result)
|
|
return result
|
|
|
|
|
|
def discard_autoresearch_candidate(root: str | Path | None = None) -> dict[str, Any]:
|
|
repo_root = resolve_autoresearch_root(root)
|
|
state = load_autoresearch_state(repo_root)
|
|
pending = _require_pending_experiment(state)
|
|
metrics = ExperimentMetrics(**pending.experiment)
|
|
final_status = 'crash' if not metrics.success or metrics.val_bpb is None else 'discard'
|
|
_git(repo_root, ['git', 'reset', '--hard', pending.base_commit], check=False)
|
|
append_results_row(
|
|
repo_root,
|
|
commit=pending.commit,
|
|
metrics=metrics,
|
|
status=final_status,
|
|
description=pending.description,
|
|
results_path=pending.results_tsv,
|
|
)
|
|
updated_state = replace_autoresearch_state(
|
|
state,
|
|
branch=_current_branch(repo_root),
|
|
pending_experiment=None,
|
|
last_decision=final_status,
|
|
updated_at=_iso_now(),
|
|
)
|
|
save_autoresearch_state(updated_state, repo_root)
|
|
result = {
|
|
'decision': final_status,
|
|
'reverted_to_commit': pending.base_commit,
|
|
'current_commit': short_head_commit(repo_root),
|
|
'state': updated_state.to_dict(),
|
|
'experiment': pending.experiment,
|
|
}
|
|
append_memory_event(repo_root, final_status, result)
|
|
return result
|
|
|
|
|
|
def retry_autoresearch_candidate(root: str | Path | None = None) -> dict[str, Any]:
|
|
repo_root = resolve_autoresearch_root(root)
|
|
state = load_autoresearch_state(repo_root)
|
|
pending = _require_pending_experiment(state)
|
|
packet = AutoresearchExperimentPacket.from_dict(pending.packet)
|
|
experiment = run_experiment(
|
|
root=repo_root,
|
|
command=packet.train_command,
|
|
log_path=packet.log_path,
|
|
timeout_seconds=packet.timeout_seconds,
|
|
)
|
|
previous_best = state.best_bpb if state.best_bpb is not None else best_recorded_bpb(repo_root, packet.results_tsv)
|
|
recommended_status = _recommend_status(experiment, previous_best)
|
|
refreshed = PendingExperiment(
|
|
commit=pending.commit,
|
|
base_commit=pending.base_commit,
|
|
description=pending.description,
|
|
packet=pending.packet,
|
|
worker=pending.worker,
|
|
experiment=experiment.to_dict(),
|
|
recommended_status=recommended_status,
|
|
results_tsv=pending.results_tsv,
|
|
log_path=pending.log_path,
|
|
created_at=pending.created_at,
|
|
)
|
|
updated_state = replace_autoresearch_state(
|
|
state,
|
|
pending_experiment=refreshed.to_dict(),
|
|
updated_at=_iso_now(),
|
|
)
|
|
save_autoresearch_state(updated_state, repo_root)
|
|
result = {
|
|
'state': updated_state.to_dict(),
|
|
'experiment': experiment.to_dict(),
|
|
'recommended_status': recommended_status,
|
|
'commit': pending.commit,
|
|
'description': pending.description,
|
|
}
|
|
append_memory_event(repo_root, 'retry', result)
|
|
return result
|
|
|
|
|
|
def loop_autoresearch(
|
|
packet_source: AutoresearchExperimentPacket | str | Path,
|
|
root: str | Path | None = None,
|
|
model: str = DEFAULT_OLLAMA_MODEL,
|
|
host: str = DEFAULT_OLLAMA_HOST,
|
|
iterations: int = 1,
|
|
retry_limit: int = 1,
|
|
require_isolated_branch: bool = True,
|
|
trace: bool = False,
|
|
) -> dict[str, Any]:
|
|
if iterations < 1:
|
|
raise ValueError('iterations must be >= 1')
|
|
repo_root = resolve_autoresearch_root(root)
|
|
if require_isolated_branch and not _is_isolated_branch(_current_branch(repo_root)):
|
|
raise RuntimeError(
|
|
'autoresearch loop requires an isolated branch named autoresearch/<tag>. '
|
|
'Run `python3 -m src.main autoresearch isolate --root ... --branch autoresearch/<tag> --create` first, '
|
|
'or pass --allow-any-branch for a local smoke test.'
|
|
)
|
|
packet = packet_source if isinstance(packet_source, AutoresearchExperimentPacket) else load_autoresearch_packet(packet_source)
|
|
setup = setup_autoresearch(repo_root, initialize_results=True)
|
|
baseline = ensure_autoresearch_baseline(repo_root, results_tsv=packet.results_tsv)
|
|
history: list[dict[str, Any]] = []
|
|
|
|
for iteration in range(1, iterations + 1):
|
|
run_result = run_autoresearch_packet(packet, root=repo_root, model=model, host=host, trace=trace)
|
|
retries = 0
|
|
while run_result.get('recommended_status') == 'crash' and retries < retry_limit:
|
|
retries += 1
|
|
run_result = retry_autoresearch_candidate(repo_root)
|
|
run_result['retry_count'] = retries
|
|
|
|
state = load_autoresearch_state(repo_root)
|
|
if state.pending_experiment is None:
|
|
history.append(
|
|
{
|
|
'iteration': iteration,
|
|
'run': run_result,
|
|
'decision': {
|
|
'decision': 'no_pending_experiment',
|
|
'state': state.to_dict(),
|
|
},
|
|
}
|
|
)
|
|
continue
|
|
|
|
decision = (
|
|
keep_autoresearch_candidate(repo_root)
|
|
if run_result.get('recommended_status') == 'keep'
|
|
else discard_autoresearch_candidate(repo_root)
|
|
)
|
|
history.append(
|
|
{
|
|
'iteration': iteration,
|
|
'run': run_result,
|
|
'decision': decision,
|
|
}
|
|
)
|
|
|
|
result = {
|
|
'setup': setup.to_dict(),
|
|
'baseline': baseline,
|
|
'state': load_autoresearch_state(repo_root).to_dict(),
|
|
'history': history,
|
|
}
|
|
append_memory_event(repo_root, 'loop_complete', {'iterations': iterations, 'history_size': len(history)})
|
|
return result
|
|
|
|
|
|
def load_autoresearch_state(root: str | Path | None = None) -> AutoresearchState:
|
|
repo_root = resolve_autoresearch_root(root)
|
|
path = repo_root / DEFAULT_STATE_FILE
|
|
if not path.exists():
|
|
state = AutoresearchState(
|
|
root=str(repo_root),
|
|
branch=_current_branch(repo_root),
|
|
updated_at=_iso_now(),
|
|
)
|
|
save_autoresearch_state(state, repo_root)
|
|
return state
|
|
payload = json.loads(path.read_text(encoding='utf-8'))
|
|
return AutoresearchState.from_dict(payload)
|
|
|
|
|
|
def save_autoresearch_state(state: AutoresearchState, root: str | Path | None = None) -> Path:
|
|
repo_root = resolve_autoresearch_root(root or state.root)
|
|
path = repo_root / DEFAULT_STATE_FILE
|
|
path.write_text(json.dumps(state.to_dict(), indent=2), encoding='utf-8')
|
|
return path
|
|
|
|
|
|
def append_memory_event(root: str | Path | None, event_type: str, payload: dict[str, Any]) -> Path:
|
|
repo_root = resolve_autoresearch_root(root)
|
|
path = repo_root / DEFAULT_MEMORY_FILE
|
|
with path.open('a', encoding='utf-8') as handle:
|
|
handle.write(json.dumps({'time': _iso_now(), 'event': event_type, 'payload': payload}) + '\n')
|
|
return path
|
|
|
|
|
|
def replace_autoresearch_state(state: AutoresearchState, **changes: Any) -> AutoresearchState:
|
|
payload = state.to_dict()
|
|
payload.update(changes)
|
|
return AutoresearchState.from_dict(payload)
|
|
|
|
|
|
def autoresearch_status(root: str | Path | None = None) -> dict[str, Any]:
|
|
repo_root = resolve_autoresearch_root(root)
|
|
state = load_autoresearch_state(repo_root)
|
|
setup = setup_autoresearch(repo_root, initialize_results=True)
|
|
memory_path = repo_root / DEFAULT_MEMORY_FILE
|
|
results_path = repo_root / DEFAULT_RESULTS_TSV
|
|
return {
|
|
'setup': setup.to_dict(),
|
|
'state': state.to_dict(),
|
|
'current_branch': _current_branch(repo_root),
|
|
'isolated_branch': _is_isolated_branch(_current_branch(repo_root)),
|
|
'paths': {
|
|
'state_file': str((repo_root / DEFAULT_STATE_FILE).resolve()),
|
|
'memory_file': str(memory_path.resolve()),
|
|
'results_tsv': str(results_path.resolve()),
|
|
},
|
|
'memory_event_count': _count_memory_events(memory_path),
|
|
'has_pending_experiment': state.pending_experiment is not None,
|
|
}
|
|
|
|
|
|
def ensure_autoresearch_branch(
|
|
root: str | Path | None = None,
|
|
branch: str | None = None,
|
|
create: bool = False,
|
|
from_ref: str = 'HEAD',
|
|
) -> dict[str, Any]:
|
|
repo_root = resolve_autoresearch_root(root)
|
|
previous_branch = _current_branch(repo_root)
|
|
target_branch = branch or (previous_branch if _is_isolated_branch(previous_branch) else _suggest_branch_name())
|
|
if not _is_isolated_branch(target_branch):
|
|
raise ValueError(f'branch must start with {AUTORESEARCH_BRANCH_PREFIX}')
|
|
created = False
|
|
if previous_branch != target_branch:
|
|
verify = _git(repo_root, ['git', 'rev-parse', '--verify', target_branch], check=False)
|
|
if verify.returncode == 0:
|
|
_git(repo_root, ['git', 'checkout', target_branch], check=True)
|
|
elif create:
|
|
_git(repo_root, ['git', 'checkout', '-b', target_branch, from_ref], check=True)
|
|
created = True
|
|
else:
|
|
raise RuntimeError(
|
|
f'Branch {target_branch} does not exist. Re-run with create=True or use '
|
|
'`python3 -m src.main autoresearch isolate --create`.'
|
|
)
|
|
state = load_autoresearch_state(repo_root)
|
|
updated_state = replace_autoresearch_state(
|
|
state,
|
|
branch=_current_branch(repo_root),
|
|
updated_at=_iso_now(),
|
|
)
|
|
save_autoresearch_state(updated_state, repo_root)
|
|
result = {
|
|
'root': str(repo_root),
|
|
'previous_branch': previous_branch,
|
|
'current_branch': _current_branch(repo_root),
|
|
'isolated_branch': True,
|
|
'created': created,
|
|
'suggested_branch': _suggest_branch_name(),
|
|
}
|
|
append_memory_event(repo_root, 'branch_isolated', result)
|
|
return result
|
|
|
|
|
|
def _project_changed_files(paths: list[str]) -> list[str]:
|
|
return sorted(path for path in paths if not path.startswith('.port_'))
|
|
|
|
|
|
def _recommend_status(metrics: ExperimentMetrics, previous_best: float | None) -> str:
|
|
if not metrics.success or metrics.val_bpb is None:
|
|
return 'crash'
|
|
if previous_best is None or metrics.val_bpb < previous_best:
|
|
return 'keep'
|
|
return 'discard'
|
|
|
|
|
|
def _commit_train_change(root: Path, description: str) -> str:
|
|
message = f'autoresearch: {description.strip()}'
|
|
subprocess.run(['git', 'add', '--', 'train.py'], cwd=root, capture_output=True, text=True, check=False)
|
|
commit = subprocess.run(
|
|
['git', 'commit', '-m', message[:120]],
|
|
cwd=root,
|
|
capture_output=True,
|
|
text=True,
|
|
check=False,
|
|
)
|
|
if commit.returncode == 0:
|
|
return short_head_commit(root)
|
|
head = short_head_commit(root)
|
|
return f'{head}-dirty' if head != 'unknown' else 'uncommitted'
|
|
|
|
|
|
def _require_pending_experiment(state: AutoresearchState) -> PendingExperiment:
|
|
if state.pending_experiment is None:
|
|
raise RuntimeError('No pending autoresearch experiment is recorded.')
|
|
return PendingExperiment.from_dict(state.pending_experiment)
|
|
|
|
|
|
def _current_branch(root: Path) -> str:
|
|
result = _git(root, ['git', 'branch', '--show-current'], check=False)
|
|
branch = result.stdout.strip()
|
|
return branch or 'DETACHED'
|
|
|
|
|
|
def _git(root: Path, args: list[str], check: bool = False) -> subprocess.CompletedProcess[str]:
|
|
result = subprocess.run(args, cwd=root, capture_output=True, text=True, check=False)
|
|
if check and result.returncode != 0:
|
|
raise RuntimeError(result.stderr.strip() or result.stdout.strip() or f'git command failed: {" ".join(args)}')
|
|
return result
|
|
|
|
|
|
def _iso_now() -> str:
|
|
return datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace('+00:00', 'Z')
|
|
|
|
|
|
def _count_memory_events(path: Path) -> int:
|
|
if not path.exists():
|
|
return 0
|
|
return sum(1 for line in path.read_text(encoding='utf-8').splitlines() if line.strip())
|
|
|
|
|
|
def _suggest_branch_name() -> str:
|
|
return f'{AUTORESEARCH_BRANCH_PREFIX}{datetime.now().strftime("%b%d").lower()}'
|
|
|
|
|
|
def _is_isolated_branch(branch: str) -> bool:
|
|
return branch.startswith(AUTORESEARCH_BRANCH_PREFIX)
|