Skip to content

Commit 6d654b3

Browse files
committed
feat(inference): emit MVP-shaped attributed event payloads
Extend the legacy inference JSONL output so possession-engine events now also produce attributed MVP event rows shaped for the deterministic event-rule engine. This preserves the existing raw pass/catch/steal rows while adding rule-validated pass, catch, steal, and derived turnover payloads with stat deltas where the current evidence supports them. Also thread the prior-handler id through steal events so turnovers can be attributed, add focused inference tests for the new adapter behavior, and advance the plan frontier to the next evidence gap: shot and rebound attribution once better ball-result signals are available.
1 parent 0e05ac9 commit 6d654b3

5 files changed

Lines changed: 152 additions & 4 deletions

File tree

TASK_STATUS.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ The old chapter-style status model is retired. This file now tracks the current
77
## Current Frontier
88

99
The current highest-priority frontier is:
10-
- `L3.71` emit first attributed MVP event payloads in the shape required by the deterministic event-rule engine
10+
- `L3.72` extend attributed MVP event emission to shot and rebound evidence once ball-result signals are available
1111
- `L3.66` add bounded multi-hypothesis identity infrastructure for ambiguous short-gap track continuity
1212
- `L3.64` add a minimal ball artifact to Layer 1 review outputs and use it to refine live-play gating
1313
- `L3.63` add explicit playback transport controls and conservative jersey-OCR display gating in the labeller
@@ -120,6 +120,7 @@ The current highest-priority frontier is:
120120
- [x] Check in an explicit Layer 1 identity-policy contract and machine-readable rules file
121121
- [x] Check in machine-readable MVP event-evidence rules and a loader for deterministic stat attribution
122122
- [x] Make the first deterministic event-attribution path consume the checked-in MVP event rules
123+
- [x] Emit first attributed MVP event payloads in the shape required by the deterministic event-rule engine
123124
- [ ] Add a minimal ball artifact to Layer 1 review outputs and use it to refine live-play gating
124125
- [ ] Add bounded multi-hypothesis identity infrastructure for ambiguous short-gap track continuity
125126
- [ ] Add explicit playback transport controls and conservative jersey-OCR display gating in the labeller
@@ -155,7 +156,7 @@ The current highest-priority frontier is:
155156
- [ ] Implement dynamic perception audit script (scripts/run_perception_audit.sh)
156157

157158
### Active or Next
158-
- [ ] Emit first attributed MVP event payloads in the shape required by the deterministic event-rule engine
159+
- [ ] Extend attributed MVP event emission to shot and rebound evidence once ball-result signals are available
159160
- [ ] Add bounded multi-hypothesis identity infrastructure for ambiguous short-gap track continuity
160161
- [ ] Add a minimal ball artifact to Layer 1 review outputs and use it to refine live-play gating
161162
- [ ] Add explicit playback transport controls and conservative jersey-OCR display gating in the labeller

docs/plan/PLAN_TREE.yaml

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -517,6 +517,11 @@ levels:
517517
parent: L2.13
518518
title: Emit first attributed MVP event payloads in the shape required by the deterministic event-rule engine
519519
owner: A0
520+
status: completed
521+
- id: L3.72
522+
parent: L2.13
523+
title: Extend attributed MVP event emission to shot and rebound evidence once ball-result signals are available
524+
owner: A0
520525
status: in_progress
521526
- id: L3.42
522527
parent: L2.32
@@ -601,7 +606,7 @@ levels:
601606

602607
priorities:
603608
current_frontier:
604-
- L3.71
609+
- L3.72
605610
- L3.66
606611
- L3.64
607612
- L3.63

pipelines/behavior_engine.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -112,7 +112,7 @@ def update(self, player_tracks, ball_pos_3d, t_ms):
112112
})
113113
else:
114114
events.append({
115-
"kind": "steal", "player_id": best_tid, "t_ms": t_ms,
115+
"kind": "steal", "player_id": best_tid, "from": self.current_handler, "t_ms": t_ms,
116116
"x": float(ball_pos_3d[0]), "y": float(ball_pos_3d[1])
117117
})
118118
self.last_handler = self.current_handler

pipelines/inference.py

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99

1010
from pipelines.behavior_engine import BehaviorStateMachine, PossessionEngine
1111
from pipelines.geometry import lift_keypoints_to_3d, project_pixel_to_court
12+
from pipelines.mvp_event_engine import MvpEventRuleEngine
1213

1314

1415
def get_label_map(spec_path="specs/basketball_ncaa.yaml"):
@@ -284,6 +285,108 @@ def write(self, payload):
284285
self._fh.write(json.dumps(payload) + "\n")
285286

286287

288+
class MvpEventAdapter:
289+
"""Translate legacy runtime events into MVP-attribution payloads.
290+
291+
The legacy inference path already emits simple `pass` / `catch` / `steal`
292+
events. This adapter preserves those rows and adds a parallel
293+
`attributed_event` contract shaped for the deterministic MVP event-rule
294+
engine.
295+
"""
296+
297+
def __init__(self, rule_engine=None):
298+
self.rule_engine = rule_engine or MvpEventRuleEngine()
299+
300+
def adapt(self, raw_event, player_map):
301+
kind = raw_event.get("kind")
302+
if kind == "catch":
303+
return [self._build_and_count(
304+
event_type="catch",
305+
actor_id=raw_event.get("player_id"),
306+
team_id=self._team_for(player_map, raw_event.get("player_id")),
307+
t_ms=raw_event.get("t_ms"),
308+
evidence={},
309+
)]
310+
if kind == "pass":
311+
return [self._build_and_count(
312+
event_type="pass",
313+
actor_id=raw_event.get("from"),
314+
secondary_actor_id=raw_event.get("to"),
315+
team_id=raw_event.get("team_id") or self._team_for(player_map, raw_event.get("from")),
316+
t_ms=raw_event.get("t_ms"),
317+
next_event_type="catch",
318+
evidence={},
319+
)]
320+
if kind == "steal":
321+
outputs = []
322+
offender_id = raw_event.get("from")
323+
if offender_id is not None:
324+
outputs.append(self._build_and_count(
325+
event_type="turnover",
326+
actor_id=offender_id,
327+
secondary_actor_id=raw_event.get("player_id"),
328+
team_id=self._team_for(player_map, offender_id),
329+
t_ms=raw_event.get("t_ms"),
330+
next_event_type="steal",
331+
evidence={"loss_of_team_control": True},
332+
))
333+
outputs.append(self._build_and_count(
334+
event_type="steal",
335+
actor_id=raw_event.get("player_id"),
336+
secondary_actor_id=offender_id,
337+
team_id=raw_event.get("team_id") or self._team_for(player_map, raw_event.get("player_id")),
338+
t_ms=raw_event.get("t_ms"),
339+
evidence={"possession_gain": True},
340+
))
341+
return outputs
342+
return []
343+
344+
def _build_and_count(
345+
self,
346+
*,
347+
event_type,
348+
actor_id,
349+
team_id,
350+
t_ms,
351+
evidence,
352+
secondary_actor_id=None,
353+
preceding_event_type=None,
354+
next_event_type=None,
355+
terminal_event_type=None,
356+
shot_value=None,
357+
):
358+
payload = {
359+
"kind": "attributed_event",
360+
"event_type": event_type,
361+
"actor_id": int(actor_id) if actor_id is not None else None,
362+
"secondary_actor_id": int(secondary_actor_id) if secondary_actor_id is not None else None,
363+
"team_id": int(team_id) if team_id is not None else None,
364+
"t_ms": int(t_ms) if t_ms is not None else None,
365+
"live_play": True,
366+
"preceding_event_type": preceding_event_type,
367+
"next_event_type": next_event_type,
368+
"terminal_event_type": terminal_event_type,
369+
"shot_value": shot_value,
370+
"evidence": evidence or {},
371+
}
372+
payload["rule_validation"] = self.rule_engine.validate_event(payload)
373+
payload["stat_deltas"] = (
374+
{}
375+
if payload["rule_validation"]
376+
else self.rule_engine.stat_deltas_for_event(payload)
377+
)
378+
return payload
379+
380+
@staticmethod
381+
def _team_for(player_map, player_id):
382+
if player_id is None:
383+
return None
384+
player_state = player_map.get(player_id)
385+
if not player_state:
386+
return None
387+
return player_state.get("team")
388+
389+
287390
class FrameResultAdapter:
288391
"""Translate raw Ultralytics frame results into pipeline-friendly pieces.
289392
@@ -366,6 +469,7 @@ def __init__(self, config, models, calibration):
366469
self.frame_idx = 0
367470
self.tracks = {}
368471
self.possession_engine = PossessionEngine()
472+
self.mvp_event_adapter = MvpEventAdapter()
369473
self.last_ball_2d = np.array([0.0, 0.0])
370474

371475
def run(self):
@@ -433,6 +537,8 @@ def _write_possession_events(self, player_map, ball_3d, t_ms, writer):
433537
possession_events = self.possession_engine.update(player_map, ball_3d, t_ms)
434538
for event in possession_events:
435539
writer.write(event)
540+
for attributed_event in self.mvp_event_adapter.adapt(event, player_map):
541+
writer.write(attributed_event)
436542

437543
def _write_player_events(self, player_map, h_matrix, t_ms, writer):
438544
"""Emit one player-state row per currently visible tracked player."""

tests/test_inference_pipeline.py

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
BALL_CLASS_ID,
1010
CalibrationResolver,
1111
FrameResultAdapter,
12+
MvpEventAdapter,
1213
)
1314

1415

@@ -75,5 +76,40 @@ def test_extract_ball_state_updates_last_ball_and_writes_event(self):
7576
self.assertEqual(writer.rows[0]["t_ms"], 250)
7677

7778

79+
class MvpEventAdapterTest(unittest.TestCase):
80+
def setUp(self):
81+
self.adapter = MvpEventAdapter()
82+
self.player_map = {
83+
7: {"team": 1},
84+
9: {"team": 1},
85+
14: {"team": 2},
86+
}
87+
88+
def test_adapt_pass_emits_attributed_event(self):
89+
rows = self.adapter.adapt(
90+
{"kind": "pass", "from": 7, "to": 9, "team_id": 1, "t_ms": 500},
91+
self.player_map,
92+
)
93+
self.assertEqual(len(rows), 1)
94+
self.assertEqual(rows[0]["kind"], "attributed_event")
95+
self.assertEqual(rows[0]["event_type"], "pass")
96+
self.assertEqual(rows[0]["actor_id"], 7)
97+
self.assertEqual(rows[0]["secondary_actor_id"], 9)
98+
self.assertEqual(rows[0]["stat_deltas"], {})
99+
self.assertEqual(rows[0]["rule_validation"], [])
100+
101+
def test_adapt_steal_emits_turnover_and_steal(self):
102+
rows = self.adapter.adapt(
103+
{"kind": "steal", "player_id": 14, "from": 7, "team_id": 2, "t_ms": 800},
104+
self.player_map,
105+
)
106+
self.assertEqual([row["event_type"] for row in rows], ["turnover", "steal"])
107+
turnover, steal = rows
108+
self.assertEqual(turnover["stat_deltas"]["TOs"], 1)
109+
self.assertEqual(steal["stat_deltas"]["Steals"], 1)
110+
self.assertEqual(turnover["rule_validation"], [])
111+
self.assertEqual(steal["rule_validation"], [])
112+
113+
78114
if __name__ == "__main__":
79115
unittest.main()

0 commit comments

Comments
 (0)