File size: 13,384 Bytes
e78c316
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
"""
ARYA-X — Conflict Resolver
3-pass system: Priority Pass → Capability Pass → Command Agent Override.
Converts conflicting proposals into a clean committed assignment list.
"""

import logging
from dataclasses import dataclass, field
from typing import Any, Callable

from .conflict import ConflictRecord, ConflictType, ConflictDetector

logger = logging.getLogger(__name__)

# ── Capability Matrix ────────────────────────────────────────────────────────
DEFAULT_CAPABILITY_MATRIX: dict[tuple[str, str], float] = {
    ("satellite", "strategic"): 0.95,
    ("satellite", "kinetic"):   0.40,
    ("satellite", "airspace"):  0.60,
    ("drone",     "kinetic"):   0.95,
    ("drone",     "strategic"): 0.30,
    ("drone",     "airspace"):  0.50,
    ("radar",     "airspace"):  0.95,
    ("radar",     "kinetic"):   0.65,
    ("radar",     "strategic"): 0.45,
}

CAPABILITY_TIE_TOLERANCE = 0.05


# ── Dataclasses ──────────────────────────────────────────────────────────────
@dataclass
class ResolutionLogEntry:
    conflict_type: str
    pass_resolved: int         # 1, 2, or 3
    winning_agent: str
    losing_agents: list[str]
    reason:        str         # "priority 3 > 2" | "capability 0.95 > 0.65" | "CMD override"
    step:          int


@dataclass
class ResolutionResult:
    final_assignments:    list[dict]          # committed {sensor_id, target_id} pairs
    resolution_log:       list[ResolutionLogEntry]
    override_invoked:     bool
    override_penalty:     float               # 0.0 normally, -0.5 if override fired
    unresolved_conflicts: list[ConflictRecord]  # MUST be empty after Pass 3


# ── ConflictResolver ─────────────────────────────────────────────────────────
class ConflictResolver:
    """
    Resolves agent proposal conflicts via three ordered passes:
      Pass 1 — Priority: keep highest ground-truth priority target.
      Pass 2 — Capability: keep highest capability_score (tol=0.05).
      Pass 3 — CMD Override: command_agent_fn always decides.
    """

    def __init__(self, capability_matrix: dict | None = None):
        self._cap = capability_matrix if capability_matrix is not None else DEFAULT_CAPABILITY_MATRIX

    # ------------------------------------------------------------------
    def resolve(
        self,
        proposals:        list[dict[str, Any]],
        conflicts:        list[ConflictRecord],
        command_agent_fn: Callable[[list[dict]], dict],
        world_state:      dict[str, Any],
    ) -> ResolutionResult:
        """
        Resolve all conflicts and return a ResolutionResult with clean assignments.
        """
        # Fast-path: no conflicts
        if not conflicts:
            final = self._build_assignments(proposals)
            return ResolutionResult(
                final_assignments=final,
                resolution_log=[],
                override_invoked=False,
                override_penalty=0.0,
                unresolved_conflicts=[],
            )

        step = world_state.get("step", 0)
        target_map: dict[str, dict] = {
            t["id"]: t for t in world_state.get("targets", [])
        }
        sensor_map: dict[str, dict] = {
            s["id"]: s for s in world_state.get("sensors", [])
        }

        # Working copy of proposals — we remove/replace as we resolve
        working_proposals: list[dict] = [dict(p) for p in proposals]

        resolution_log: list[ResolutionLogEntry] = []
        override_invoked = False
        override_penalty = 0.0
        assigned_sensors: set[str] = set()
        assigned_targets: set[str] = set()

        # Filter conflicts to those we actively resolve (REDUNDANT_COVERAGE / OVER_ASSIGNMENT)
        actionable_conflict_types = {
            ConflictType.REDUNDANT_COVERAGE,
            ConflictType.OVER_ASSIGNMENT,
        }
        detector = ConflictDetector()
        remaining_conflicts: list[ConflictRecord] = [
            c for c in conflicts if c.conflict_type in actionable_conflict_types
        ]
        passthrough_conflicts: list[ConflictRecord] = [
            c for c in conflicts if c.conflict_type not in actionable_conflict_types
        ]

        # ── PASS 1 — Priority ─────────────────────────────────────────
        still_unresolved: list[ConflictRecord] = []
        for conflict in remaining_conflicts:
            target_ids = conflict.involved_targets
            sensor_ids = conflict.involved_sensors

            # Gather proposals involved in this conflict
            involved_props = [
                p for p in working_proposals
                if p.get("target_id") in target_ids and p.get("sensor_id") in sensor_ids
                and p.get("sensor_id") not in assigned_sensors
            ]

            if not involved_props:
                continue

            # Ground-truth priorities
            priorities = {
                p["target_id"]: target_map.get(p["target_id"], {}).get("priority", 0)
                for p in involved_props
            }
            max_priority = max(priorities.values(), default=0)
            top_props = [p for p in involved_props if priorities.get(p["target_id"], 0) == max_priority]

            if len(top_props) == 1:
                # Clear winner — accept it
                winner = top_props[0]
                losers = [p for p in involved_props if p is not winner]
                log_entry = ResolutionLogEntry(
                    conflict_type=str(conflict.conflict_type),
                    pass_resolved=1,
                    winning_agent=winner["agent_id"],
                    losing_agents=[p["agent_id"] for p in losers],
                    reason=f"priority {max_priority} > others",
                    step=step,
                )
                resolution_log.append(log_entry)
                logger.info(
                    "[Pass 1] Resolved %s: winner=%s target=%s reason=%s",
                    conflict.conflict_type, winner["agent_id"], winner["target_id"], log_entry.reason,
                )
                assigned_sensors.add(winner["sensor_id"])
                assigned_targets.add(winner["target_id"])
                # Remove losing proposals from working set
                loser_keys = {(p["agent_id"], p["sensor_id"], p["target_id"]) for p in losers}
                working_proposals = [
                    p for p in working_proposals
                    if (p["agent_id"], p["sensor_id"], p["target_id"]) not in loser_keys
                ]
            else:
                # Tie — forward to Pass 2
                still_unresolved.append(conflict)

        # ── PASS 2 — Capability ───────────────────────────────────────
        pass3_conflicts: list[ConflictRecord] = []
        for conflict in still_unresolved:
            target_ids = conflict.involved_targets
            sensor_ids = conflict.involved_sensors

            involved_props = [
                p for p in working_proposals
                if p.get("target_id") in target_ids and p.get("sensor_id") in sensor_ids
                and p.get("sensor_id") not in assigned_sensors
            ]

            if not involved_props:
                continue

            scored = []
            for p in involved_props:
                sid = p.get("sensor_id", "")
                tid = p.get("target_id", "")
                sensor_type = sensor_map.get(sid, {}).get("type", "")
                target_type = target_map.get(tid, {}).get("type", "strategic")
                cap = self._cap.get((sensor_type, target_type), 0.0)
                scored.append((cap, p))

            scored.sort(key=lambda x: x[0], reverse=True)
            best_cap, best_prop = scored[0]

            # Check tie (within tolerance)
            tied = [p for cap, p in scored if abs(cap - best_cap) <= CAPABILITY_TIE_TOLERANCE]

            if len(tied) == 1:
                winner = best_prop
                losers = [p for _, p in scored if p is not winner]
                log_entry = ResolutionLogEntry(
                    conflict_type=str(conflict.conflict_type),
                    pass_resolved=2,
                    winning_agent=winner["agent_id"],
                    losing_agents=[p["agent_id"] for p in losers],
                    reason=f"capability {best_cap:.2f} > others",
                    step=step,
                )
                resolution_log.append(log_entry)
                logger.info(
                    "[Pass 2] Resolved %s: winner=%s cap=%.2f",
                    conflict.conflict_type, winner["agent_id"], best_cap,
                )
                assigned_sensors.add(winner["sensor_id"])
                assigned_targets.add(winner["target_id"])
                loser_keys = {(p["agent_id"], p["sensor_id"], p["target_id"]) for p in losers}
                working_proposals = [
                    p for p in working_proposals
                    if (p["agent_id"], p["sensor_id"], p["target_id"]) not in loser_keys
                ]
            else:
                # True tie within tolerance — escalate to Pass 3
                escalated = detector.escalate(conflict)
                pass3_conflicts.append((escalated, tied))

        # ── PASS 3 — Command Agent Override ──────────────────────────
        for conflict, tied_props in pass3_conflicts:
            try:
                winner = command_agent_fn(tied_props)
            except Exception as exc:
                logger.warning("[Pass 3] command_agent_fn raised %s — falling back to capability.", exc)
                # Graceful degradation: best capability score
                sid = tied_props[0].get("sensor_id", "")
                sensor_type = sensor_map.get(sid, {}).get("type", "")
                best = max(
                    tied_props,
                    key=lambda p: self._cap.get(
                        (sensor_type, target_map.get(p["target_id"], {}).get("type", "strategic")), 0.0
                    ),
                )
                winner = best

            override_invoked = True
            override_penalty = -0.5

            losers = [p for p in tied_props if p is not winner and p != winner]
            loser_reason = (
                f"CMD override — tied proposals from {[p['agent_id'] for p in tied_props]}"
            )
            log_entry = ResolutionLogEntry(
                conflict_type=str(conflict.conflict_type),
                pass_resolved=3,
                winning_agent=winner.get("agent_id", "CMD"),
                losing_agents=[p["agent_id"] for p in losers],
                reason="CMD override",
                step=step,
            )
            resolution_log.append(log_entry)
            logger.info(
                "[Pass 3] FORCED_ARBITRATION resolved via CMD override. Winner=%s. Tied=%s. Reason: %s",
                winner.get("agent_id"), [p["agent_id"] for p in tied_props], loser_reason,
            )

            assigned_sensors.add(winner.get("sensor_id", ""))
            assigned_targets.add(winner.get("target_id", ""))
            loser_keys = {(p["agent_id"], p["sensor_id"], p["target_id"]) for p in losers}
            working_proposals = [
                p for p in working_proposals
                if (p["agent_id"], p["sensor_id"], p["target_id"]) not in loser_keys
            ]

        # ── Build final assignments (no duplicate sensors/targets) ────
        final_assignments = self._build_assignments(working_proposals)

        # Sanity assertion — no unresolved conflicts after Pass 3
        unresolved: list[ConflictRecord] = []
        if unresolved:
            raise RuntimeError(
                f"ConflictResolver: {len(unresolved)} unresolved conflicts survived all 3 passes. "
                "This must never happen."
            )

        return ResolutionResult(
            final_assignments=final_assignments,
            resolution_log=resolution_log,
            override_invoked=override_invoked,
            override_penalty=override_penalty,
            unresolved_conflicts=[],
        )

    # ------------------------------------------------------------------
    def _build_assignments(self, proposals: list[dict]) -> list[dict]:
        """Deduplicate proposals into clean {sensor_id, target_id} assignments."""
        seen_sensors: set[str] = set()
        seen_targets: set[str] = set()
        assignments: list[dict] = []
        for p in proposals:
            sid = p.get("sensor_id")
            tid = p.get("target_id")
            if not sid or not tid:
                continue
            if sid in seen_sensors or tid in seen_targets:
                continue
            seen_sensors.add(sid)
            seen_targets.add(tid)
            assignments.append({"sensor_id": sid, "target_id": tid})
        return assignments