把派发从「能力子集 + 空闲」升级为任务为中心的多维可解释打分匹配。 新增/改动: - 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>
345 lines
15 KiB
Python
345 lines
15 KiB
Python
"""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",
|
||
]
|