agent-harness / src /agent_harness /robustness_experiment.py
cuber12's picture
Publish agent harness research code and paper artifacts
d61821a verified
Raw
History Blame Contribute Delete
22.7 kB
"""E04 stale-index and plausible-distractor robustness experiment."""
from __future__ import annotations
from dataclasses import asdict
from hashlib import sha256
import json
from pathlib import Path
import time
from typing import Any, Sequence
from .components import Candidate
from .confirmatory_retrieval import extended_metrics, memory_sample, retrieve_treatment, subprocess_git
from .lm_studio_embeddings import LMStudioEmbeddingClient
from .pilot import research_code_revision
from .repository import GitSnapshot, SourceChunk, SourceFile, chunk_file, chunk_snapshot
from .retrieval import BM25FuzzyRetriever, DenseRetriever, ExactRetriever, SQLiteEmbeddingCache, query_terms
from .specs import HarnessSpec, TaskSpec, load_embeddings, load_experiments, load_harnesses, load_models, load_task_split, load_tasks
from .syntax_index import GoSymbol, SymbolGraph, SyntaxRetriever, parse_go_file, parse_snapshot
from .telemetry import EventWriter, RunIdentity, run_directory
from .tokenization import QwenTokenCounter
from .vector_backends import FaissFlatRetriever
E02_QUERY_REVISION = "1a7066f6c7682793b6f04445c776a93cc4fac895"
DISTRACTOR_SEVERITIES = {0: 1, 1: 5, 2: 10}
DISTRACTOR_PREFIX = "__harness_distractors__"
class RobustnessExperimentError(RuntimeError):
"""Raised when the frozen E04 robustness protocol cannot be preserved."""
def distractor_severity(seed: int) -> int:
"""Map the frozen E04 seed coordinate to a nested distractor dose."""
try:
return DISTRACTOR_SEVERITIES[seed]
except KeyError as exc:
raise RobustnessExperimentError(f"E04 has no distractor dose for seed {seed}") from exc
def synthetic_distractor_sources(task: TaskSpec, severity: int) -> tuple[SourceFile, ...]:
"""Create deterministic, valid, in-memory Go distractors with issue vocabulary."""
if severity not in set(DISTRACTOR_SEVERITIES.values()):
raise RobustnessExperimentError(f"unsupported distractor severity {severity}")
terms = query_terms(task.statement)
vocabulary = " ".join(terms[:40]) or "repository repair"
issue = " ".join(task.statement.split())
task_name = "".join(part.capitalize() for part in task.task_id.lower().split("_"))
sources: list[SourceFile] = []
for index in range(1, severity + 1):
path = f"{DISTRACTOR_PREFIX}/{task.task_id.lower()}/distractor_{index:03d}.go"
name = f"{task_name}PlausibleResolver{index:03d}"
text = (
"package harnessdistractor\n\n"
f"// {name} appears related to this issue: {issue}\n"
f"type {name} struct {{\n"
"\tEnabled bool\n"
"}\n\n"
f"// Resolve handles {vocabulary}.\n"
f"func (value {name}) Resolve() string {{\n"
f"\treturn {json.dumps(vocabulary)}\n"
"}\n"
)
sources.append(SourceFile(path=path, text=text))
return tuple(sources)
def robustness_shift(
baseline: dict[str, Any],
perturbed: dict[str, Any],
baseline_paths: Sequence[str],
perturbed_paths: Sequence[str],
gold_files: Sequence[str],
missing_rank: int,
) -> dict[str, Any]:
"""Compute paired degradation measures, including a predeclared censored rank."""
baseline_rank = baseline["first_gold_rank"]
perturbed_rank = perturbed["first_gold_rank"]
baseline_censored = baseline_rank if baseline_rank is not None else missing_rank
perturbed_censored = perturbed_rank if perturbed_rank is not None else missing_rank
baseline_gold_at_10 = set(baseline_paths[:10]) & set(gold_files)
perturbed_gold_at_10 = set(perturbed_paths[:10]) & set(gold_files)
retention = (
None
if not baseline_gold_at_10
else len(baseline_gold_at_10 & perturbed_gold_at_10) / len(baseline_gold_at_10)
)
return {
"file_recall_at_10_delta": perturbed["file_recall_at_10"] - baseline["file_recall_at_10"],
"mrr_delta": perturbed["mrr"] - baseline["mrr"],
"ndcg_at_10_delta": perturbed["ndcg_at_10"] - baseline["ndcg_at_10"],
"first_gold_rank_displacement": (
None if baseline_rank is None or perturbed_rank is None else perturbed_rank - baseline_rank
),
"first_gold_rank_displacement_censored": perturbed_censored - baseline_censored,
"baseline_gold_top_10_retention": retention,
"lost_all_top_10_gold": bool(baseline_gold_at_10 and not perturbed_gold_at_10),
}
def load_iterative_query(root: Path, task_id: str) -> dict[str, Any]:
path = (
root
/ "results"
/ "staging"
/ "E02"
/ E02_QUERY_REVISION
/ "H010"
/ task_id
/ "query_stage.json"
)
if not path.exists():
raise RobustnessExperimentError(f"missing pinned E02 query artifact: {path}")
value = json.loads(path.read_text(encoding="utf-8"))
if value.get("task_id") != task_id or value.get("harness_id") != "H010":
raise RobustnessExperimentError(f"mismatched E02 query artifact: {path}")
if value.get("query_source") not in {"model", "issue_fallback"}:
raise RobustnessExperimentError(f"unamended E02 query artifact: {path}")
if not isinstance(value.get("query"), str) or not value["query"].strip():
raise RobustnessExperimentError(f"empty E02 query artifact: {path}")
return value
def _build_index(
chunks: Sequence[SourceChunk],
symbols: Sequence[GoSymbol],
embedding_spec: Any,
embedding_client: LMStudioEmbeddingClient,
embedding_cache: SQLiteEmbeddingCache,
) -> tuple[ExactRetriever, BM25FuzzyRetriever, SyntaxRetriever, FaissFlatRetriever, SymbolGraph, dict[str, Any]]:
started = time.monotonic()
dense_base, dense_stats = DenseRetriever.build(
chunks, embedding_spec, embedding_client, embedding_cache
)
dense = FaissFlatRetriever(dense_base)
return (
ExactRetriever(chunks),
BM25FuzzyRetriever(chunks),
SyntaxRetriever(symbols),
dense,
SymbolGraph(symbols),
{
"source_file_count": len({chunk.path for chunk in chunks}),
"source_chunk_count": len(chunks),
"symbol_count": len(symbols),
"dense_total_chunks": dense_stats.total_chunks,
"dense_cached_chunks": dense_stats.cached_chunks,
"dense_embedded_chunks": dense_stats.embedded_chunks,
"dense_vector_load_seconds": dense_stats.build_seconds,
"faiss_build_seconds": dense.stats.build_seconds,
"total_index_seconds": time.monotonic() - started,
},
)
def _retrieve(
harness: HarnessSpec,
query: str,
index: tuple[ExactRetriever, BM25FuzzyRetriever, SyntaxRetriever, FaissFlatRetriever, SymbolGraph, dict[str, Any]],
limit: int,
) -> tuple[Candidate, ...]:
return retrieve_treatment(harness, query, *index[:5], limit)
def _ranking(candidates: Sequence[Candidate], gold_files: Sequence[str]) -> list[dict[str, Any]]:
gold = set(gold_files)
return [
{
"rank": rank,
"path": candidate.path,
"line_start": candidate.line_start,
"line_end": candidate.line_end,
"score": candidate.score,
"source": candidate.source,
"symbol": candidate.symbol,
"is_gold_file": candidate.path in gold,
"is_synthetic_distractor": candidate.path.startswith(f"{DISTRACTOR_PREFIX}/"),
}
for rank, candidate in enumerate(candidates, start=1)
]
def _metrics(
candidates: Sequence[Candidate],
task: TaskSpec,
evaluation_symbols: Sequence[GoSymbol],
tokenizer: QwenTokenCounter,
context_budget: int,
) -> dict[str, Any]:
return extended_metrics(
candidates,
task.gold_files,
task.gold_symbols,
evaluation_symbols,
tokenizer,
context_budget,
)
def run_robustness_experiment(
root: Path,
repository: Path,
experiment_id: str = "E04",
task_filter: set[str] | None = None,
harness_filter: set[str] | None = None,
seed_filter: set[int] | None = None,
candidate_limit: int = 200,
) -> dict[str, Any]:
"""Run or resume every selected E04 composite robustness cell."""
code_revision = research_code_revision(root)
experiment = load_experiments(root).get(experiment_id)
if experiment is None or experiment.mode != "robustness":
raise RobustnessExperimentError("runner requires the frozen E04 robustness experiment")
task_catalog = load_tasks(root)
split = load_task_split(root / "tasks" / "splits" / f"{experiment.task_split}.txt")
tasks = [task_catalog[item] for item in split if task_filter is None or item in task_filter]
harness_catalog = load_harnesses(root)
harnesses = [
harness_catalog[item]
for item in experiment.harness_ids
if harness_filter is None or item in harness_filter
]
seeds = [seed for seed in experiment.seeds if seed_filter is None or seed in seed_filter]
if not tasks or not harnesses or not seeds:
raise RobustnessExperimentError("filters selected no E04 cells")
if set(experiment.seeds) != set(DISTRACTOR_SEVERITIES):
raise RobustnessExperimentError("E04 seeds no longer match the frozen dose mapping")
model_spec = load_models(root)[experiment.model_ids[0]]
embedding_spec = load_embeddings(root)[experiment.embedding_id]
embedding_client = LMStudioEmbeddingClient(embedding_spec, timeout_seconds=120.0)
embedding_runtime = embedding_client.resolve()
resident_models = embedding_client.loaded_model_keys()
if resident_models != (embedding_spec.model_key,):
raise RobustnessExperimentError(
f"E04 requires exclusive embedding-model residency; observed {resident_models}"
)
snapshot = GitSnapshot(repository)
origin = subprocess_git(repository, ["remote", "get-url", "origin"])
if {task.repository_url for task in tasks} != {origin}:
raise RobustnessExperimentError("repository origin does not match frozen tasks")
tokenizer = QwenTokenCounter()
cache_path = root / "indexes" / "embeddings" / f"{embedding_spec.config_hash}.sqlite3"
rows: list[dict[str, Any]] = []
with SQLiteEmbeddingCache(cache_path, embedding_spec) as embedding_cache:
for task in tasks:
parent_commit = subprocess_git(repository, ["rev-parse", f"{task.base_commit}^"])
base_chunks = chunk_snapshot(
snapshot,
task.base_commit,
embedding_spec.chunk_lines,
embedding_spec.chunk_overlap_lines,
embedding_spec.chunk_char_limit,
)
base_symbols = parse_snapshot(snapshot, task.base_commit)
parent_chunks = chunk_snapshot(
snapshot,
parent_commit,
embedding_spec.chunk_lines,
embedding_spec.chunk_overlap_lines,
embedding_spec.chunk_char_limit,
)
parent_symbols = parse_snapshot(snapshot, parent_commit)
base_index = _build_index(
base_chunks, base_symbols, embedding_spec, embedding_client, embedding_cache
)
stale_index = _build_index(
parent_chunks, parent_symbols, embedding_spec, embedding_client, embedding_cache
)
for harness in harnesses:
query_record = (
load_iterative_query(root, task.task_id)
if harness.harness_id == "H010"
else {
"query": task.statement,
"query_source": "issue_statement",
"protocol_violation": None,
}
)
query = query_record["query"]
baseline_started = time.monotonic()
baseline_candidates = _retrieve(harness, query, base_index, candidate_limit)
baseline_query_seconds = time.monotonic() - baseline_started
stale_started = time.monotonic()
stale_candidates = _retrieve(harness, query, stale_index, candidate_limit)
stale_query_seconds = time.monotonic() - stale_started
baseline_metrics = _metrics(
baseline_candidates, task, base_symbols, tokenizer, experiment.context_budgets[0]
)
stale_metrics = _metrics(
stale_candidates, task, base_symbols, tokenizer, experiment.context_budgets[0]
)
stale_shift = robustness_shift(
baseline_metrics,
stale_metrics,
[candidate.path for candidate in baseline_candidates],
[candidate.path for candidate in stale_candidates],
task.gold_files,
candidate_limit + 1,
)
for seed in seeds:
identity = RunIdentity(
experiment_id=experiment.experiment_id,
task_id=task.task_id,
harness_id=harness.harness_id,
harness_hash=harness.config_hash,
model_id=model_spec.model_id,
model_key=model_spec.expected_inference_key,
model_config_hash=model_spec.config_hash,
context_budget=experiment.context_budgets[0],
seed=seed,
repetition=0,
repository_sha=task.base_commit,
code_revision=code_revision,
)
directory = run_directory(root / "results", identity)
if directory.exists():
final_path = directory / "final_metrics.json"
if not final_path.exists():
raise RobustnessExperimentError(f"incomplete pre-existing run: {directory}")
rows.append(json.loads(final_path.read_text(encoding="utf-8")))
continue
severity = distractor_severity(seed)
sources = synthetic_distractor_sources(task, severity)
distractor_chunks: list[SourceChunk] = list(base_chunks)
distractor_symbols: list[GoSymbol] = list(base_symbols)
for source in sources:
distractor_chunks.extend(
chunk_file(
source,
embedding_spec.chunk_lines,
embedding_spec.chunk_overlap_lines,
embedding_spec.chunk_char_limit,
)
)
distractor_symbols.extend(parse_go_file(source.path, source.text))
distractor_index = _build_index(
distractor_chunks,
distractor_symbols,
embedding_spec,
embedding_client,
embedding_cache,
)
distractor_started = time.monotonic()
distractor_candidates = _retrieve(
harness, query, distractor_index, candidate_limit
)
distractor_query_seconds = time.monotonic() - distractor_started
distractor_metrics = _metrics(
distractor_candidates,
task,
base_symbols,
tokenizer,
experiment.context_budgets[0],
)
distractor_paths = [candidate.path for candidate in distractor_candidates]
distractor_shift = robustness_shift(
baseline_metrics,
distractor_metrics,
[candidate.path for candidate in baseline_candidates],
distractor_paths,
task.gold_files,
candidate_limit + 1,
)
synthetic_top_10 = [
path for path in distractor_paths[:10] if path.startswith(f"{DISTRACTOR_PREFIX}/")
]
final = {
"run_id": identity.run_id,
"experiment_id": "E04",
"task_id": task.task_id,
"harness_id": harness.harness_id,
"seed": seed,
"query": query,
"query_source": query_record["query_source"],
"query_protocol_violation": query_record.get("protocol_violation"),
"baseline": {
"index_commit": task.base_commit,
"metrics": baseline_metrics,
"query_seconds": baseline_query_seconds,
"index_stats": base_index[5],
},
"stale_index": {
"scenario_id": "S001",
"index_commit": parent_commit,
"evaluation_commit": task.base_commit,
"shared_key": f"{task.task_id}:{harness.harness_id}:parent_commit",
"shared_across_seeds": True,
"metrics": stale_metrics,
"shift": stale_shift,
"query_seconds": stale_query_seconds,
"index_stats": stale_index[5],
},
"plausible_distractors": {
"scenario_id": "S002",
"severity": severity,
"nested_dose": True,
"synthetic_file_count": len(sources),
"synthetic_in_top_10_count": len(synthetic_top_10),
"synthetic_paths_in_top_10": synthetic_top_10,
"metrics": distractor_metrics,
"shift": distractor_shift,
"query_seconds": distractor_query_seconds,
"index_stats": distractor_index[5],
},
}
manifest = [
{
"path": source.path,
"sha256": sha256(source.text.encode("utf-8")).hexdigest(),
"text": source.text,
}
for source in sources
]
with EventWriter(
root / "results",
identity,
asdict(harness),
{
"agent_model_not_loaded": asdict(model_spec),
"embedding_model": asdict(embedding_spec),
"embedding_runtime": embedding_runtime,
"tokenizer_path": str(tokenizer.path),
"tokenizer_sha256": tokenizer.sha256,
},
) as writer:
writer.emit(
"run_started",
{
"confirmatory": True,
"composite_scenarios": ["S001", "S002"],
"stale_shared_across_seeds": True,
"distractor_severity": severity,
"candidate_limit": candidate_limit,
"task_config_hash": task.config_hash,
},
)
writer.emit("resource_sample", memory_sample())
writer.write_artifact(
"baseline_ranking.json",
json.dumps(_ranking(baseline_candidates, task.gold_files), indent=2) + "\n",
)
writer.write_artifact(
"stale_ranking.json",
json.dumps(_ranking(stale_candidates, task.gold_files), indent=2) + "\n",
)
writer.write_artifact(
"distractor_ranking.json",
json.dumps(_ranking(distractor_candidates, task.gold_files), indent=2) + "\n",
)
writer.write_artifact(
"distractor_sources.json", json.dumps(manifest, indent=2) + "\n"
)
writer.write_artifact(
"query_record.json", json.dumps(query_record, indent=2) + "\n"
)
writer.write_artifact(
"final_metrics.json", json.dumps(final, indent=2) + "\n"
)
writer.emit("resource_sample", memory_sample())
writer.emit("run_finished", {"status": "completed", "metrics": final})
rows.append(final)
summary = {
"schema_version": 1,
"experiment_id": experiment.experiment_id,
"confirmatory": True,
"code_revision": code_revision,
"repository": str(repository.resolve()),
"repository_origin": origin,
"embedding_config_hash": embedding_spec.config_hash,
"tokenizer_sha256": tokenizer.sha256,
"resident_models": resident_models,
"task_count": len(tasks),
"harness_count": len(harnesses),
"seed_count": len(seeds),
"run_count": len(rows),
"stale_unique_units": len(tasks) * len(harnesses),
"stale_inference_rule": "deduplicate by stale_index.shared_key",
"distractor_seed_dose_mapping": DISTRACTOR_SEVERITIES,
"runs": rows,
}
report = (
root
/ "results"
/ "reports"
/ f"{experiment.experiment_id}_{code_revision[:12]}_{int(time.time())}.json"
)
report.parent.mkdir(parents=True, exist_ok=True)
report.write_text(json.dumps(summary, indent=2, sort_keys=True) + "\n", encoding="utf-8")
summary["report_path"] = str(report)
return summary