Files
Agentswarm/benchmark/collectors/run_collector.py
T
gongzhiyongandClaude Opus 4.8 28a6c618ab
CI / tests (push) Failing after 11m18s
CI / guardrails (push) Failing after 11m19s
feat(benchmark): SC-10 P_rework 计入评审重开(#10)
修正 P_rework 已知低估:此前 ReworkCount 仅由 retry_count>0 派生,漏了 cross_review/queen
质量门的重开(reopen_task 不增 retry_count——是质量决策非失败)。
- cross_review + queen_quality_gate 重开时累计 run.metadata["rework_reopens"]
- run_collector:rework_count = retry 派生 + rework_reopens

测试 test-benchmark-collector/metrics 通过。影响:仅 benchmark 度量(P_rework 更准),不改运行路径。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 22:51:49 +08:00

208 lines
10 KiB
Python

"""SwarmRunMetricsCollector — compute SwarmMetrics from a real swarm run.
Wires benchmark.metrics formulas to a run's tasks + event stream (orchestrator state).
Only metrics with real data sources are computed; the rest are returned as NaN and flagged
in `coverage` (False) — we do NOT fake a 0/100 score for uncollected metrics (rule #9).
Collected when the run exercises the relevant path (else NaN + coverage False):
- completion, collaboration, robustness → always, from tasks + handoff events
- cost → when the plan has a budget and tasks report usage
- governance → when the run had governed ops / approvals
- communication → when peers exchanged messages (request→reply rate, internal telemetry)
- reward → when the run was graded against a fixture (Q_quality real) + speed/cost present
- tau/eta/p_decision → when ENABLE_ACO_DISPATCH sampled assignments (recorded decisions)
Uncollected today (need work flagged in docs/benchmark/swarm-metrics-schema.md):
- gain → needs baselines (emergence-evaluation)
"""
from __future__ import annotations
import json
import math
from collections import Counter
from . import SwarmMetricsCollector
from ..metrics import (
SwarmMetrics, completion_score, collaboration_score, communication_score, cost_score,
robustness_score, governance_score, reward, speed_score, rework_penalty,
)
def _task_cost(task) -> float:
"""Extract model_cost_usd from a task's stored result, 0.0 if absent."""
result = getattr(task, "result", None)
if not result:
return 0.0
try:
data = json.loads(result) if isinstance(result, str) else result
return float((data.get("usage") or {}).get("model_cost_usd") or 0.0)
except Exception:
return 0.0
class SwarmRunMetricsCollector(SwarmMetricsCollector):
"""Collect SwarmMetrics for one swarm run (by swarm_id)."""
def __init__(self, swarm_id: str):
self.swarm_id = swarm_id
self.coverage: dict[str, bool] = {}
async def collect(self) -> SwarmMetrics:
# Lazy import: the collector reads live orchestrator state.
from orchestrator.swarm_runtime import swarm_runtime
from orchestrator.task_queue import task_queue, TaskStatus
run = await swarm_runtime.get_run(self.swarm_id)
if not run:
raise ValueError(f"run not found: {self.swarm_id}")
tasks = [t for t in [await task_queue.get_task(tid) for tid in run.task_ids] if t]
events = (await swarm_runtime.list_events(self.swarm_id, limit=10000)).get("events", [])
event_types = Counter(e.get("event_type") for e in events)
def status(t):
return t.status.value if hasattr(t.status, "value") else t.status
# --- s_completion (real) ---
total = len(tasks)
completed = sum(1 for t in tasks if status(t) == TaskStatus.COMPLETED.value)
s_completion = completion_score(completed, total)
self.coverage["s_completion"] = total > 0
# --- s_collaboration (real) ---
req = event_types.get("handoff.requested", 0)
comp = event_types.get("handoff.completed", 0)
handoff_success = (100.0 * comp / req) if req else 100.0
dep_tasks = [t for t in tasks if t.depends_on]
done_ids = {t.task_id for t in tasks if status(t) == TaskStatus.COMPLETED.value}
resolved = [t for t in dep_tasks if all(d in done_ids for d in t.depends_on)]
dep_resolution = (100.0 * len(resolved) / len(dep_tasks)) if dep_tasks else 100.0
per_agent = Counter(t.assigned_agent_id for t in tasks if t.assigned_agent_id)
if per_agent:
counts = list(per_agent.values())
workload_balance = 100.0 * (min(counts) / max(counts))
else:
workload_balance = 100.0
s_collaboration = collaboration_score(handoff_success, dep_resolution, workload_balance)
self.coverage["s_collaboration"] = total > 0
# --- s_robustness (real) ---
failures = [t for t in tasks if t.retry_count > 0 or status(t) == TaskStatus.FAILED.value]
recovered = [t for t in failures if status(t) == TaskStatus.COMPLETED.value]
s_robustness = robustness_score(len(recovered), len(failures))
self.coverage["s_robustness"] = True
# --- s_cost (real if budget + usage present) ---
plan = (run.request_body or {}).get("orchestration_plan") or {}
budget = plan.get("budget") or {}
max_cost = budget.get("max_cost_usd")
actual = sum(_task_cost(t) for t in tasks)
if isinstance(max_cost, (int, float)) and actual > 0:
s_cost = cost_score(float(max_cost), actual)
self.coverage["s_cost"] = True
else:
s_cost = math.nan
self.coverage["s_cost"] = False
# --- s_governance (real only if the run had governed ops / approvals) ---
approvals = list((run.approvals or {}).values())
if approvals:
compliant = sum(1 for a in approvals if a.get("decision") in ("approved", "rejected"))
s_governance = governance_score(compliant, len(approvals))
self.coverage["s_governance"] = True
else:
s_governance = math.nan
self.coverage["s_governance"] = False
# --- s_communication (real only if the run exchanged peer messages) ---
# successful = peer requests that received a matching reply (by correlation_id);
# total = distinct peer requests routed. A run with no peer collaboration → NaN.
collab = run.collaboration or {}
requests = set(collab.get("request_correlations") or [])
replies = set(collab.get("reply_correlations") or [])
if requests:
answered = len(requests & replies)
s_communication = communication_score(answered, len(requests))
self.coverage["s_communication"] = True
else:
s_communication = math.nan
self.coverage["s_communication"] = False
# --- reward (real only when the run was graded against a fixture: Group B) ---
# Needs Q_quality (fixture TestPassRate, masked-renormalized), a V_speed target_time, and a
# real E_cost. r_robust/g_gov derive from the run; p_rework from retries; p_risk from the
# approval risk levels (0 when no governed op was observed — a known under-approximation tied
# to the governance coverage gap, see docs/benchmark/metric-coverage-gaps.md).
quality = run.quality or {}
q_quality = quality.get("q_quality")
target_time = quality.get("target_time_seconds")
duration = max(0.0, float(run.updated_at or 0) - float(run.created_at or 0))
have_quality = isinstance(q_quality, (int, float)) and not math.isnan(q_quality)
have_speed = isinstance(target_time, (int, float)) and target_time and duration > 0
if have_quality and have_speed and self.coverage["s_cost"]:
# P_rework counts BOTH transient retries AND quality-driven review/queen reopens
# (reopen_task doesn't bump retry_count — it's a quality decision, not a failure;
# SC-10 fixes the prior under-count). Reopen tally accumulated on run.metadata.
rework_count = (sum(1 for t in tasks if getattr(t, "retry_count", 0) > 0)
+ int((run.metadata or {}).get("rework_reopens", 0) or 0))
risky = sum(1 for a in approvals
if str(a.get("risk_level", "")).lower() in {"high", "critical"})
p_risk = (100.0 * risky / len(approvals)) if approvals else 0.0
g_gov = governance_score(
sum(1 for a in approvals if a.get("decision") in ("approved", "rejected")),
len(approvals),
) # empty -> 100 (no governed-op violations observed)
reward_value = reward(
s_task=s_completion,
q_quality=float(q_quality),
v_speed=speed_score(float(target_time), duration),
e_cost=s_cost,
r_robust=s_robustness,
g_gov=g_gov,
p_risk=p_risk,
p_rework=rework_penalty(rework_count, total),
)
self.coverage["reward"] = True
else:
reward_value = math.nan
self.coverage["reward"] = False
# --- tau / eta / p_decision (real only when ACO dispatch recorded decisions: Group A) ---
# Run-level value = mean over the run's sampled assignments. p_decision uses the
# standard's §3.3 score (τ^α·η^β·100), recorded per decision as p_score. No decisions
# (flag off, or no ACO-dispatched task) → NaN.
decisions = [d for d in (run.decisions or [])
if isinstance(d.get("tau"), (int, float)) and isinstance(d.get("eta"), (int, float))]
if decisions:
tau = sum(d["tau"] for d in decisions) / len(decisions)
eta = sum(d["eta"] for d in decisions) / len(decisions)
p_dec = sum(float(d.get("p_score") or 0.0) for d in decisions) / len(decisions)
self.coverage["tau"] = self.coverage["eta"] = self.coverage["p_decision"] = True
else:
tau = eta = p_dec = math.nan
self.coverage["tau"] = self.coverage["eta"] = self.coverage["p_decision"] = False
# --- not yet collectable (see docs/benchmark/metric-coverage-gaps.md) ---
# s_gain needs baselines; s_swarm/g_e/g_e_cost/benchmark depend on it
# (any NaN component → NaN aggregate).
for k in ("s_gain", "s_swarm", "g_e", "g_e_cost", "benchmark"):
self.coverage[k] = False
return SwarmMetrics(
tau=tau,
eta=eta,
p_decision=p_dec,
reward=reward_value,
s_completion=s_completion,
s_gain=math.nan,
s_collaboration=s_collaboration,
s_communication=s_communication,
s_cost=s_cost,
s_robustness=s_robustness,
s_governance=s_governance,
s_swarm=math.nan,
g_e=math.nan,
g_e_cost=math.nan,
benchmark=math.nan,
)