traffix-live / src /coflow5 /control /request_arbitration.py
REBELLION2002's picture
Deploy live TraffiX app (fix/live-container-ci e8a6317, showcase network omitted)
ec5ee81 verified
Raw History Blame Contribute Delete
13.7 kB
from __future__ import annotations
import hashlib
import math
from dataclasses import dataclass, field
from datetime import datetime, timezone
from types import MappingProxyType
from typing import Mapping
from coflow5.control.safety import (
ActionProposal,
DeterministicSafetyMask,
ProposalSource,
SafetyReason,
SignalSnapshot,
)
from coflow5.messaging.board import MessageTransport
from coflow5.messaging.envelope import (
PRIORITY_RANK,
MessageEnvelope,
PriorityClass,
Topic,
)
@dataclass(frozen=True)
class ArbitrationContext:
run_id: str
scenario_hash: str
signal_id: str
simulation_time: float
current_phase_id: str
phase_entered_at: float
flow_requested_phase_id: str
downstream_feasible_by_movement: Mapping[str, bool] = field(default_factory=dict)
pedestrian_remaining_clearance: Mapping[str, float] = field(default_factory=dict)
def __post_init__(self) -> None:
if not all((self.run_id, self.scenario_hash, self.signal_id, self.current_phase_id)):
raise ValueError("arbitration identity and current phase are required")
if not all(math.isfinite(value) for value in (self.simulation_time, self.phase_entered_at)):
raise ValueError("arbitration times must be finite")
if self.simulation_time < self.phase_entered_at:
raise ValueError("simulation time cannot precede phase entry")
clearances = dict(self.pedestrian_remaining_clearance)
if any(not math.isfinite(value) or value < 0 for value in clearances.values()):
raise ValueError("pedestrian clearances must be finite and non-negative")
object.__setattr__(
self, "downstream_feasible_by_movement",
MappingProxyType(dict(self.downstream_feasible_by_movement)),
)
object.__setattr__(self, "pedestrian_remaining_clearance", MappingProxyType(clearances))
@dataclass(frozen=True)
class RequestDecision:
run_id: str
scenario_hash: str
event_id: str
referenced_message_id: str
reply_message_id: str
simulation_time: float
recorded_at: str
source_agent: str
priority_class: str
accepted: bool
selected_action: str
reason_code: str
safety_reason_code: str
constraints: tuple[str, ...]
provenance: str = "a1-deterministic-request-arbitration-v1"
@dataclass(frozen=True)
class ArbitrationRound:
selected_action: str
decisions: tuple[RequestDecision, ...]
replies: tuple[MessageEnvelope, ...]
@dataclass(frozen=True)
class _Candidate:
message: MessageEnvelope
requested_phase_id: str
requested_movement: str
expected_benefit: float
externality: float
urgency: float
eta: float
safety_reason: SafetyReason
@property
def priority_rank(self) -> int:
return PRIORITY_RANK[self.message.priority_class.value]
@property
def net_benefit(self) -> float:
return self.expected_benefit - self.externality
class A1RequestArbitrator:
"""Pure request arbitration; accepted actions still require normal A1 submission."""
def __init__(self, *, transport: MessageTransport, safety_mask: DeterministicSafetyMask) -> None:
self._transport = transport
self._safety_mask = safety_mask
def arbitrate(self, context: ArbitrationContext) -> ArbitrationRound:
reads = (
self._transport.read(
Topic.REQUESTS.value,
valid_at=context.simulation_time,
run_id=context.run_id,
scenario_hash=context.scenario_hash,
),
self._transport.read(
Topic.ECO.value,
valid_at=context.simulation_time,
run_id=context.run_id,
scenario_hash=context.scenario_hash,
),
)
allowed_topics = {Topic.REQUESTS.value, Topic.ECO.value}
requests = tuple(
message for read in reads for message in read.messages
if message.destination_or_topic in allowed_topics
and message.payload.get("signal_id") == context.signal_id
)
active_clearance = dict(context.pedestrian_remaining_clearance)
for message in requests:
if message.payload_type != "active_crossing_state":
continue
if message.payload.get("active_crossing") is not True:
continue
crossing = message.payload.get("crossing_id")
remaining = message.payload.get("remaining_clearance_seconds")
if isinstance(crossing, str) and self._finite_number(remaining) and float(remaining) > 0:
active_clearance[crossing] = max(active_clearance.get(crossing, 0.0), float(remaining))
snapshot = SignalSnapshot(
phase_id=context.current_phase_id,
phase_entered_at=context.phase_entered_at,
pedestrian_remaining_clearance=active_clearance,
)
feasible: list[_Candidate] = []
pre_rejections: dict[str, tuple[str, str]] = {}
for message in requests:
parsed = self._candidate(message, context, snapshot)
if isinstance(parsed, tuple):
pre_rejections[message.message_id] = parsed
else:
feasible.append(parsed)
ranked = sorted(feasible, key=self._rank_key)
winner = ranked[0] if ranked else None
selected_action = winner.requested_phase_id if winner else context.flow_requested_phase_id
decisions: list[RequestDecision] = []
replies: list[MessageEnvelope] = []
for message in requests:
candidate = next((item for item in feasible if item.message.message_id == message.message_id), None)
if message.message_id in pre_rejections:
reason_code, safety_reason = pre_rejections[message.message_id]
accepted = False
elif winner is not None and candidate is not None and message.message_id == winner.message.message_id:
accepted = True
reason_code = self._accepted_reason(message.priority_class)
safety_reason = candidate.safety_reason.value
else:
accepted = False
safety_reason = candidate.safety_reason.value if candidate else "NOT_EVALUATED"
reason_code = (
"REJECTED_SAME_TIER_TIEBREAK"
if winner is not None
and candidate is not None
and candidate.priority_rank == winner.priority_rank
else "REJECTED_HIGHER_PRIORITY_REQUEST"
)
event_id = self._event_id(context, message.message_id)
reply_message_id = f"reply-{event_id}"
decision = RequestDecision(
run_id=context.run_id,
scenario_hash=context.scenario_hash,
event_id=event_id,
referenced_message_id=message.message_id,
reply_message_id=reply_message_id,
simulation_time=context.simulation_time,
recorded_at=datetime.now(timezone.utc).isoformat(),
source_agent=message.source_agent,
priority_class=message.priority_class.value,
accepted=accepted,
selected_action=selected_action,
reason_code=reason_code,
safety_reason_code=safety_reason,
constraints=(
"priority=active_safety>emergency>pedestrian_deadline>late_transit>flow>sustainability",
"same_tier=urgency_then_net_benefit_then_externality_then_eta_then_expiry_then_message_id",
"downstream-feasibility-required",
"accepted-action-still-requires-deterministic-safety-mask-and-A1-executor",
"single-writer=A1_FLOW",
),
)
reply = MessageEnvelope(
run_id=context.run_id,
scenario_hash=context.scenario_hash,
message_id=reply_message_id,
correlation_id=f"{message.correlation_id}:{message.message_id}",
source_agent="A1_FLOW",
destination_or_topic=Topic.REPLIES.value,
created_at=datetime.now(timezone.utc),
simulation_time=context.simulation_time,
expires_at=max(context.simulation_time, message.expires_at),
priority_class=message.priority_class,
confidence=1.0,
schema_version=1,
payload_type="request_decision_reply",
payload={
"referenced_message_id": message.message_id,
"event_id": event_id,
"accepted": accepted,
"selected_action": selected_action,
"reason_code": reason_code,
"safety_reason_code": safety_reason,
},
provenance=decision.provenance,
)
result = self._transport.publish(reply, valid_at=context.simulation_time)
if not result.accepted or result.message is None:
raise RuntimeError(f"A1 reply publication failed: {result.disposition.value}")
decisions.append(decision)
replies.append(result.message)
decisions.sort(key=lambda item: item.referenced_message_id)
replies.sort(key=lambda item: item.message_id)
return ArbitrationRound(selected_action, tuple(decisions), tuple(replies))
def _candidate(
self,
message: MessageEnvelope,
context: ArbitrationContext,
snapshot: SignalSnapshot,
) -> _Candidate | tuple[str, str]:
payload = message.payload
phase = payload.get("requested_phase_id")
movement = payload.get("requested_movement")
benefit = payload.get("expected_benefit_seconds", 0.0)
externality = payload.get(
"civilian_delay_externality_seconds",
payload.get("other_traffic_externality_seconds", 0.0),
)
urgency = payload.get("urgency", 0.0)
eta = payload.get("eta_seconds", math.inf)
if (
not isinstance(phase, str) or not phase
or not isinstance(movement, str) or not movement
or not self._finite_non_negative(benefit)
or not self._finite_non_negative(externality)
or not self._finite_non_negative(urgency)
or not (eta == math.inf or self._finite_non_negative(eta))
):
return "REJECTED_INVALID_REQUEST", "NOT_EVALUATED"
if payload.get("downstream_available") is False:
return "REJECTED_DOWNSTREAM_BLOCKED", "NOT_EVALUATED"
if not context.downstream_feasible_by_movement.get(movement, True):
return "REJECTED_DOWNSTREAM_BLOCKED", "NOT_EVALUATED"
safety = self._safety_mask.evaluate(
ActionProposal(
proposal_id=f"arbitrate-{message.message_id}",
signal_id=context.signal_id,
requested_phase_id=phase,
source=ProposalSource.A1_FLOW,
),
snapshot,
context.simulation_time,
)
if not safety.accepted:
return safety.reason.value, safety.reason.value
return _Candidate(
message=message,
requested_phase_id=phase,
requested_movement=movement,
expected_benefit=float(benefit),
externality=float(externality),
urgency=float(urgency),
eta=float(eta),
safety_reason=safety.reason,
)
@staticmethod
def _rank_key(candidate: _Candidate) -> tuple[float | str, ...]:
return (
-candidate.priority_rank,
-candidate.urgency,
-candidate.net_benefit,
-candidate.expected_benefit,
candidate.externality,
candidate.eta,
candidate.message.expires_at,
candidate.message.message_id,
)
@staticmethod
def _accepted_reason(priority: PriorityClass) -> str:
return {
PriorityClass.ACTIVE_SAFETY: "ACCEPTED_ACTIVE_CROSSING_CLEARANCE",
PriorityClass.EMERGENCY: "ACCEPTED_EMERGENCY_PRIORITY",
PriorityClass.PEDESTRIAN_DEADLINE: "ACCEPTED_PEDESTRIAN_DEADLINE",
PriorityClass.LATE_TRANSIT: "ACCEPTED_CONDITIONAL_LATE_TRANSIT",
PriorityClass.SUSTAINABILITY: "ACCEPTED_SUSTAINABILITY_ADVICE",
}.get(priority, "ACCEPTED_BOUNDED_ADVISORY")
@staticmethod
def _event_id(context: ArbitrationContext, message_id: str) -> str:
raw = (
f"{context.run_id}|{context.scenario_hash}|{context.signal_id}|"
f"{context.simulation_time:.9f}|{message_id}"
).encode()
return f"evt-{hashlib.sha256(raw).hexdigest()[:24]}"
@staticmethod
def _finite_number(value: object) -> bool:
return (
isinstance(value, (int, float))
and not isinstance(value, bool)
and math.isfinite(float(value))
)
@classmethod
def _finite_non_negative(cls, value: object) -> bool:
return cls._finite_number(value) and float(value) >= 0