Files
Agentswarm/orchestrator/dispatch_score.py
T
Songhaoz666andClaude Opus 4.8 62610a7e3f 调度评分:多维可解释打分匹配 + dispatch.decision_made(Closes #9)
把派发从「能力子集 + 空闲」升级为任务为中心的多维可解释打分匹配。

新增/改动:
- orchestrator/dispatch_score.py(新):DispatchCandidate/DispatchScore + 加权掩码归一
  打分(score_candidate/rank_candidates)+ build_dispatch_decision_event。
- orchestrator/main.py:抽出三模式共用的 finalize_dispatch;新增 scored_matchmake
  (ENABLE_DISPATCH_SCORE,默认关,与 ACO 择一)——为每个就绪任务在有能力的空闲 Agent
  间按 capability/历史成功(τ)/负载/预算压力/权限择优,记录可解释决策;_run_budget_pressure
  计算真实预算占比。
- orchestrator/swarm_runtime.py:SwarmRun.dispatch_decisions + record_dispatch_decision
  (内部状态,非 Manager 事件)。
- 测试:scripts/test-dispatch-score.py(公式 24 项)+ scripts/test-dispatch-scored.py
  (集成 11 项:按 τ/负载多 Agent 择优 + 排除原因 + 可回放记录);CI 纳入两者。
- docs/scheduling/dispatch-score-schema.md、CLAUDE.md 同步。

诚实边界:risk_score/estimated_cost/estimated_time 本仓无来源 → None 并在 payload
uncollected_dimensions 披露(不伪造,规则 #9);dispatch.decision_made 暂为 Swarm 内部
记录,未进 Manager 事件契约(需 event-schema 注册,跨端)。flag 关闭时贪心/ACO 路径逐字节不变。

Closes #9

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

345 lines
15 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Explainable dispatch scoring layer (issue #9).
This module produces the EXPLAINABLE SCORE INPUTS for capability-aware swarm
dispatch and the `dispatch.decision_made` event that surfaces them. It answers
"why was this agent×task pairing chosen, and why were the others excluded?" with
a per-dimension breakdown — going beyond the previous behavior, which matched on
capability set membership alone (`TaskQueue.can_agent_run_task`).
Boundary with #10 (`orchestrator/decision_engine.py`, already implemented):
- #10 = the PROBABILISTIC decision: normalize history into τ and a-priori
desirability into η, then SAMPLE one task with P=τ^α·η^β/Σ (ε-greedy), and
emit a replayable internal `DecisionTrace`.
- #9 (this module) = the EXPLAINABLE SCORE INPUTS + the Manager-facing
`dispatch.decision_made` event. It computes a transparent, weighted,
per-dimension score for each candidate and records exclusion reasons. It is
deterministic (no RNG) and does not itself change dispatch order.
- Shared input: `historical_success` reuses the decision_engine τ trail where
natural (τ is the cross-run learned reputation, already on [0.05, 1.0]).
Honesty (org rule #9 — no fabricated signals):
Several scheduling dimensions named in the standard have NO source in this
repo. They are represented as `None` ("not collected"), never invented:
- estimated_cost / estimated_time: no pre-execution estimator exists; model
cost/runtime are only known AFTER a task completes (see emit_usage_event in
main.py). Pre-dispatch they are None.
- risk_score: no per-task risk classifier. Approval/risk_level lives on the
Manager approval chain, not on the queue Task. None here.
- region / GPU / hardware fit: AgentMetadata carries no such fields. Not
collected — not represented at all (we do not add a fake dimension).
A `None` dimension contributes 0 to the weighted score AND drops out of the
weight normalizer (masked + renormalized), so it neither helps nor penalizes a
candidate and the score stays on [0, 1]. This mirrors metrics.quality_score().
Default-OFF: this module is pure computation and emits no event by itself. The
dispatch loop only calls it when ENABLE_DISPATCH_SCORE_EVENT is set (see the
flag helper below and the integration notes in
docs/scheduling/dispatch-score-schema.md). With the flag off, behavior is
byte-for-byte unchanged.
"""
from __future__ import annotations
import os
from dataclasses import asdict, dataclass, field
from typing import Dict, List, Optional, Sequence
# Weight of each scoring dimension. Positive dimensions reward a candidate;
# *_pressure / risk are costs the caller passes already oriented as "higher =
# worse" and we subtract them. Weights are explicit and documented so the score
# is auditable; they need not sum to 1.0 because we renormalize over the
# dimensions that actually have a signal (see score_candidate).
DISPATCH_WEIGHTS: Dict[str, float] = {
"capability_match": 0.30, # how well agent caps cover task requirements
"historical_success": 0.20, # reuse decision_engine τ (learned reputation)
"load": 0.15, # free-slot headroom (more free → higher)
"permission_fit": 0.12, # agent is allowed to run this task
"budget_pressure": 0.10, # run budget consumed so far (cost → subtract)
"risk_score": 0.08, # task risk (cost → subtract)
"estimated_cost": 0.03, # predicted $ (cost → subtract)
"estimated_time": 0.02, # predicted wall-clock (cost → subtract)
}
# Dimensions treated as costs: a higher value LOWERS the score. The rest are
# benefits: a higher value RAISES it.
COST_DIMENSIONS = frozenset({"budget_pressure", "risk_score", "estimated_cost", "estimated_time"})
DISPATCH_DECISION_EVENT_TYPE = "dispatch.decision_made"
def dispatch_score_event_enabled() -> bool:
"""Whether the dispatch loop should emit `dispatch.decision_made` (default OFF).
Pure scoring/explanation is always safe to compute; this flag only gates the
new event emission so the Manager event stream is unchanged by default.
"""
return os.getenv("ENABLE_DISPATCH_SCORE_EVENT", "false").lower() in {"1", "true", "yes"}
@dataclass
class DispatchScore:
"""Explainable, weighted score for one (agent, task) pairing.
`total` is the renormalized weighted blend of the per-dimension values in
`breakdown`. `breakdown` keeps the RAW dimension values (each on [0, 1], or
None when not collected) so a reviewer can see exactly what drove the total.
`weights_used` records the renormalized weight actually applied to each
present dimension (absent dimensions are dropped), and `missing` lists the
dimensions that had no signal — making the "not collected" set explicit in
the payload rather than silently omitted.
"""
total: float
breakdown: Dict[str, Optional[float]]
weights_used: Dict[str, float]
missing: List[str] = field(default_factory=list)
def as_dict(self) -> Dict[str, object]:
return {
"total": round(self.total, 6),
"breakdown": {k: (round(v, 6) if isinstance(v, (int, float)) else None)
for k, v in self.breakdown.items()},
"weights_used": {k: round(v, 6) for k, v in self.weights_used.items()},
"missing": list(self.missing),
}
@dataclass
class DispatchCandidate:
"""One (agent, task) pairing considered for dispatch, with its raw signals.
Every field except the identifiers is an explainable input to the score.
Fields whose signal does not exist in this repo MUST be left None by the
caller — they are not invented (org rule #9). See module docstring for the
list of inherently-None dimensions (estimated_cost/time, risk_score, region/
GPU). `score` is filled in by score_candidate().
"""
agent_id: str
task_id: str
agent_role: str = "general"
# --- benefit dimensions (higher = better), each normalized to [0, 1] ---
capability_match: float = 0.0 # Jaccard / coverage of required caps
historical_success: Optional[float] = None # decision_engine τ, normalized
load: Optional[float] = None # free-slot headroom, normalized
permission_fit: Optional[float] = None # 1.0 allowed, 0.0 disallowed
# --- cost dimensions (higher = worse), each normalized to [0, 1] ---
budget_pressure: Optional[float] = None
risk_score: Optional[float] = None
estimated_cost: Optional[float] = None
estimated_time: Optional[float] = None
# filled by score_candidate
score: Optional[DispatchScore] = None
def signal_dimensions(self) -> Dict[str, Optional[float]]:
"""Return the raw scoring inputs keyed by dimension name."""
return {
"capability_match": self.capability_match,
"historical_success": self.historical_success,
"load": self.load,
"permission_fit": self.permission_fit,
"budget_pressure": self.budget_pressure,
"risk_score": self.risk_score,
"estimated_cost": self.estimated_cost,
"estimated_time": self.estimated_time,
}
def _clamp01(x: float) -> float:
return 0.0 if x < 0.0 else (1.0 if x > 1.0 else x)
def normalize_capability_match(required: Sequence[str], capabilities: Sequence[str]) -> float:
"""Jaccard overlap of required vs. agent caps — rewards focus, matches η.
Aligned with DecisionEngine.compute_eta's `match` (Jaccard rewards focus),
with one deliberate refinement matching TaskQueue.can_agent_run_task:
a task with NO required capabilities can be run by anyone → perfect fit
(1.0), rather than the raw Jaccard 0 you would get for required=∅.
"""
required_set = set(required or [])
if not required_set:
return 1.0
caps_set = set(capabilities or [])
union = required_set | caps_set
if not union:
return 1.0
return len(required_set & caps_set) / len(union)
def normalize_tau(tau: Optional[float], *, tau_min: float = 0.05, tau_max: float = 1.0) -> Optional[float]:
"""Map a decision_engine τ trail value onto [0, 1] for historical_success.
τ already lives on [TAU_MIN, TAU_MAX]; we rescale so the breakdown is on the
same [0,1] axis as the other dimensions. None in → None out (no trail yet is
"not collected", not "zero reputation" — do not fabricate).
"""
if tau is None:
return None
span = tau_max - tau_min
if span <= 0:
return _clamp01(tau)
return _clamp01((tau - tau_min) / span)
def normalize_load(free_slots: Optional[int], *, saturation_slots: float = 2.0) -> Optional[float]:
"""Map free capacity onto [0, 1] (more headroom → higher). None → None.
Mirrors DecisionEngine RESOURCE_NORM_SLOTS so load means the same thing in
both layers.
"""
if free_slots is None:
return None
if saturation_slots <= 0:
return 1.0 if free_slots > 0 else 0.0
return _clamp01(max(0, free_slots) / saturation_slots)
def score_candidate(
candidate: DispatchCandidate,
*,
weights: Optional[Dict[str, float]] = None,
) -> DispatchScore:
"""Compute the explainable, weighted score for one candidate (pure, no RNG).
Score = Σ_{d∈present} w_d · contribution_d / Σ_{d∈present} w_d, where a
benefit dimension contributes its value and a cost dimension contributes
(1 − value). Dimensions whose value is None are masked out of BOTH the
numerator and the weight normalizer, so an uncollected signal neither helps
nor hurts and the result stays on [0, 1]. This is the same masking discipline
as benchmark.metrics.quality_score (org rule #9 — no fabricated 0/1).
All present dimensions are also written back into the returned breakdown so
the choice is fully auditable, and `missing` names the absent ones.
"""
w = weights or DISPATCH_WEIGHTS
raw = candidate.signal_dimensions()
numerator = 0.0
weight_total = 0.0
weights_used: Dict[str, float] = {}
missing: List[str] = []
for dim, value in raw.items():
if value is None:
missing.append(dim)
continue
weight = w.get(dim, 0.0)
if weight == 0.0:
# Recorded in breakdown for transparency but carries no score weight.
continue
clamped = _clamp01(float(value))
contribution = (1.0 - clamped) if dim in COST_DIMENSIONS else clamped
numerator += weight * contribution
weight_total += weight
weights_used[dim] = weight
total = (numerator / weight_total) if weight_total > 0 else 0.0
score = DispatchScore(
total=total,
breakdown=dict(raw),
weights_used=weights_used,
missing=missing,
)
candidate.score = score
return score
def rank_candidates(
candidates: Sequence[DispatchCandidate],
*,
weights: Optional[Dict[str, float]] = None,
) -> List[DispatchCandidate]:
"""Score every candidate and return them sorted best-first (stable).
Pure helper for callers/tests that want the explainable ranking without the
probabilistic sampling of decision_engine. Ties keep input order (stable
sort), so the result is deterministic.
"""
for candidate in candidates:
if candidate.score is None:
score_candidate(candidate, weights=weights)
return sorted(candidates, key=lambda c: c.score.total, reverse=True)
def build_dispatch_decision_event(
candidates: Sequence[DispatchCandidate],
chosen: Optional[DispatchCandidate],
excluded_reasons: Optional[Dict[str, str]] = None,
*,
task_id: Optional[str] = None,
weights: Optional[Dict[str, float]] = None,
) -> Dict[str, object]:
"""Build a `dispatch.decision_made` event payload (issue #9 DoD).
The payload carries:
- `candidates`: every considered (agent, task) pairing with its full
per-dimension score breakdown (raw values + renormalized weights +
the explicit `missing` list of uncollected dimensions);
- `chosen_agent_id` / `chosen_task_id`: the selected pairing (or None when
nothing dispatched);
- `excluded`: agent_id → human-readable reason a candidate was NOT chosen
(e.g. "capability_mismatch", "no_capacity", "lower_score").
This is an EXPLANATION of dispatch inputs, not the probabilistic decision
itself (#10 owns that and stores its own replayable DecisionTrace
internally). The payload follows the event-schema.md envelope convention:
the orchestrator's emit_event wraps this under `payload` and adds the
standard envelope fields (event_id/swarm_id/occurred_at/...). The caller
supplies `task_id` for the envelope's top-level task_id field too — see the
integration notes in docs/scheduling/dispatch-score-schema.md.
Any candidate not yet scored is scored here, so the payload is always
self-consistent.
"""
excluded = dict(excluded_reasons or {})
scored: List[Dict[str, object]] = []
for candidate in candidates:
score = candidate.score or score_candidate(candidate, weights=weights)
scored.append({
"agent_id": candidate.agent_id,
"task_id": candidate.task_id,
"agent_role": candidate.agent_role,
"score": score.as_dict(),
})
resolved_task_id = task_id or (chosen.task_id if chosen else None)
if resolved_task_id is None and candidates:
resolved_task_id = candidates[0].task_id
return {
"task_id": resolved_task_id,
"chosen_agent_id": chosen.agent_id if chosen else None,
"chosen_task_id": chosen.task_id if chosen else None,
"candidate_count": len(scored),
"candidates": scored,
"excluded": excluded,
"weights": dict(weights or DISPATCH_WEIGHTS),
# The dimensions that are inherently None in this repo (see module
# docstring). Surfaced so the Manager UI can show "not collected" rather
# than assume the scorer ignored them.
"uncollected_dimensions": _repo_uncollected_dimensions(),
}
def _repo_uncollected_dimensions() -> List[str]:
"""Dimensions with NO data source in this repo today (honest disclosure).
These are reported on every event so downstream consumers never mistake a
masked dimension for a low score. Kept in code (not a doc-only note) so the
disclosure ships with the payload.
"""
return ["estimated_cost", "estimated_time", "risk_score"]
__all__ = [
"DispatchCandidate",
"DispatchScore",
"DISPATCH_WEIGHTS",
"DISPATCH_DECISION_EVENT_TYPE",
"dispatch_score_event_enabled",
"normalize_capability_match",
"normalize_tau",
"normalize_load",
"score_candidate",
"rank_candidates",
"build_dispatch_decision_event",
]