from collections import defaultdict def split_by_kickoff_step_infos( events_with_possession, kickoff_step_infos, *, is_known_team_id, is_kickoff_related_event, step_stays_in_kickoff_phase, kickoff_team_id=None, fallback_team_id=None, ): """Split events into kickoff/team-turn sections using kickoff step boundaries.""" if not kickoff_step_infos: sections = [] events_remaining = list(events_with_possession) idx = 0 if is_known_team_id(kickoff_team_id): while idx < len(events_remaining) and events_remaining[idx].get("effective_active_team_id") == kickoff_team_id: idx += 1 if idx > 0: sections.append({"phase": "kickoff", "team_id": kickoff_team_id, "events": events_remaining[:idx]}) events_remaining = events_remaining[idx:] current_team = None current_events = [] for event in events_remaining: effective_active = event.get("effective_active_team_id") if not is_known_team_id(effective_active): current_events.append(event) continue if current_team is None: current_team = effective_active elif effective_active != current_team: sections.append({"phase": "team_turn", "team_id": current_team, "events": current_events}) current_team = effective_active current_events = [] current_events.append(event) if current_events: sections.append({"phase": "team_turn", "team_id": current_team if is_known_team_id(current_team) else None, "events": current_events}) if not sections: sections.append({"phase": "team_turn", "team_id": fallback_team_id if is_known_team_id(fallback_team_id) else None, "events": events_with_possession}) return sections ko_steps = sorted(kickoff_step_infos, key=lambda x: int(x[0]) if str(x[0]).isdigit() else 999999) sections = [] pending_events = [] current_team = fallback_team_id if is_known_team_id(fallback_team_id) else None ko_idx = 0 kickoff_open = False last_kickoff_section_idx = None def flush_team_section(): nonlocal pending_events if pending_events: sections.append( { "phase": "team_turn", "team_id": current_team if is_known_team_id(current_team) else None, "events": pending_events, } ) pending_events = [] def pop_kickoff_tail_events(): """Detach trailing kickoff-related step suffix from pending team events.""" nonlocal pending_events if not pending_events: return [] pending_by_step = defaultdict(list) for event in pending_events: pending_by_step[event.get("step_number")].append(event) pending_steps = sorted( pending_by_step.keys(), key=lambda value: int(value) if str(value).isdigit() else 999999, ) kickoff_tail_steps = [] for step_num in reversed(pending_steps): step_events = pending_by_step.get(step_num, []) if step_stays_in_kickoff_phase(step_events): kickoff_tail_steps.append(step_num) continue break if not kickoff_tail_steps: return [] kickoff_tail_steps = set(kickoff_tail_steps) kickoff_tail_events = [event for event in pending_events if event.get("step_number") in kickoff_tail_steps] pending_events = [event for event in pending_events if event.get("step_number") not in kickoff_tail_steps] return kickoff_tail_events def split_step_events_at_kickoff_boundary(step_events): """Split kickoff-step rows into pre-kickoff vs kickoff-boundary groups.""" kickoff_boundary_events = [] pre_kickoff_events = [] for event in step_events or []: if str(event.get("roll_category") or "") == "touchdown": pre_kickoff_events.append(event) continue if is_kickoff_related_event(event): kickoff_boundary_events.append(event) else: pre_kickoff_events.append(event) return pre_kickoff_events, kickoff_boundary_events events_by_step = defaultdict(list) for event in events_with_possession: events_by_step[event.get("step_number")].append(event) step_numbers = sorted( events_by_step.keys(), key=lambda value: int(value) if str(value).isdigit() else 999999, ) for step_num_raw in step_numbers: step_events = events_by_step[step_num_raw] try: step_num = int(step_num_raw) except (TypeError, ValueError): step_num = 999999 next_ko_step_num = None next_ko_team_id = None if ko_idx < len(ko_steps): next_ko_step_raw, next_ko_team_id = ko_steps[ko_idx] try: next_ko_step_num = int(next_ko_step_raw) except (TypeError, ValueError): next_ko_step_num = 999999 while ko_idx < len(ko_steps): ko_step_num_raw, ko_team_id = ko_steps[ko_idx] try: ko_step_num = int(ko_step_num_raw) except (TypeError, ValueError): ko_step_num = 999999 if step_num > ko_step_num: kickoff_tail_events = pop_kickoff_tail_events() flush_team_section() sections.append({"phase": "kickoff", "team_id": ko_team_id, "events": kickoff_tail_events, "kickoff_step_number": ko_step_num}) last_kickoff_section_idx = len(sections) - 1 kickoff_open = True receiver = "0" if ko_team_id == "1" else ("1" if ko_team_id == "0" else None) current_team = receiver if is_known_team_id(receiver) else None ko_idx += 1 else: break if ( not kickoff_open and ko_idx < len(ko_steps) and next_ko_step_num is not None and step_num < next_ko_step_num and any(is_kickoff_related_event(event) for event in step_events) ): pre_kickoff_events, kickoff_boundary_events = split_step_events_at_kickoff_boundary(step_events) if pre_kickoff_events: pending_events.extend(pre_kickoff_events) kickoff_tail_events = pop_kickoff_tail_events() flush_team_section() kickoff_events = kickoff_tail_events + (kickoff_boundary_events if kickoff_boundary_events else step_events) sections.append({"phase": "kickoff", "team_id": next_ko_team_id, "events": kickoff_events, "kickoff_step_number": next_ko_step_num}) last_kickoff_section_idx = len(sections) - 1 kickoff_open = True receiver = "0" if next_ko_team_id == "1" else ("1" if next_ko_team_id == "0" else None) current_team = receiver if is_known_team_id(receiver) else None continue if ko_idx < len(ko_steps): ko_step_num_raw, ko_team_id = ko_steps[ko_idx] try: ko_step_num = int(ko_step_num_raw) except (TypeError, ValueError): ko_step_num = 999999 if step_num == ko_step_num: if kickoff_open: if isinstance(last_kickoff_section_idx, int): sections[last_kickoff_section_idx]["events"].extend(step_events) else: sections.append({"phase": "kickoff", "team_id": ko_team_id, "events": step_events}) last_kickoff_section_idx = len(sections) - 1 ko_idx += 1 continue kickoff_tail_events = pop_kickoff_tail_events() flush_team_section() kickoff_events = kickoff_tail_events + step_events sections.append({"phase": "kickoff", "team_id": ko_team_id, "events": kickoff_events, "kickoff_step_number": ko_step_num}) last_kickoff_section_idx = len(sections) - 1 kickoff_open = True receiver = "0" if ko_team_id == "1" else ("1" if ko_team_id == "0" else None) current_team = receiver if is_known_team_id(receiver) else None ko_idx += 1 continue if kickoff_open and step_stays_in_kickoff_phase(step_events): if isinstance(last_kickoff_section_idx, int): sections[last_kickoff_section_idx]["events"].extend(step_events) else: sections.append({"phase": "kickoff", "team_id": None, "events": step_events}) last_kickoff_section_idx = len(sections) - 1 continue kickoff_open = False if any(str(event.get("roll_category") or "") == "armour" for event in step_events): effective_active = None for event in step_events: active = event.get("effective_active_team_id") if is_known_team_id(active): effective_active = active break if is_known_team_id(effective_active) and is_known_team_id(current_team) and effective_active != current_team: flush_team_section() current_team = effective_active pending_events.extend(step_events) continue effective_active = None for event in step_events: active = event.get("effective_active_team_id") if is_known_team_id(active): effective_active = active break if current_team is None and is_known_team_id(effective_active): current_team = effective_active elif is_known_team_id(effective_active) and is_known_team_id(current_team) and effective_active != current_team: flush_team_section() current_team = effective_active pending_events.extend(step_events) while ko_idx < len(ko_steps): ko_step_num_raw, ko_team_id = ko_steps[ko_idx] try: ko_step_num = int(ko_step_num_raw) except (TypeError, ValueError): ko_step_num = None flush_team_section() sections.append({"phase": "kickoff", "team_id": ko_team_id, "events": [], "kickoff_step_number": ko_step_num}) receiver = "0" if ko_team_id == "1" else ("1" if ko_team_id == "0" else None) current_team = receiver if is_known_team_id(receiver) else None ko_idx += 1 flush_team_section() if not sections: sections.append({"phase": "team_turn", "team_id": None, "events": events_with_possession}) return sections def step_anchor_fingerprint(step_events, *, normalize_team_id, max_rows=4): """Create a stable signature for a step, tolerant to nearby row insertions.""" if not step_events: return [] rows = [] for event in sorted( step_events, key=lambda e: ( str(e.get("roll_category")), str(e.get("payload_type")), str(e.get("result_name")), ), ): rows.append( { "cat": str(event.get("roll_category") or "-"), "payload": str(event.get("payload_type") or "-"), "result": str(event.get("result_name") or "-"), "roll_type": str(event.get("roll_type") or "-"), "team": normalize_team_id(event.get("team_id")) or "-", } ) return rows[:max_rows] def snapshot_turn_anchors(by_step, anchor_specs, *, fingerprint_builder): """Attach step fingerprints to anchor specs.""" result = [] for spec in anchor_specs or []: if not isinstance(spec, dict): continue anchor = dict(spec) step_num = anchor.get("step") if isinstance(step_num, int): anchor["fingerprint"] = fingerprint_builder(by_step.get(step_num, [])) result.append(anchor) return result def check_turn_anchors( step_lookup, event_rows, by_step, anchors, *, normalize_team_id, event_matches_filter, fingerprint_builder, drift_window=20, ): """Validate anchors and track nearby moves within a drift window.""" known_steps = sorted(by_step.keys()) reports = [] for anchor in anchors or []: if not isinstance(anchor, dict): continue name = str(anchor.get("name") or f"step-{anchor.get('step')}") expected = { "turn": str(anchor.get("expected_turn")) if anchor.get("expected_turn") is not None else None, "phase": anchor.get("expected_phase"), "team_id": normalize_team_id(anchor.get("expected_team")), } step_num = anchor.get("step") fingerprint = anchor.get("fingerprint") or [] event_filter = anchor.get("event_filter") def owner_matches(owner): return ( (expected["turn"] is None or owner.get("turn") == expected["turn"]) and (expected["phase"] is None or owner.get("phase") == expected["phase"]) and (expected["team_id"] is None or owner.get("team_id") == expected["team_id"]) ) if isinstance(step_num, int) and isinstance(event_filter, dict): exact_matches = [ row for row in event_rows if row.get("step") == step_num and event_matches_filter(row, event_filter) ] exact_owner_match = next((row for row in exact_matches if owner_matches(row)), None) if exact_owner_match is not None: reports.append( { "name": name, "status": "pass", "anchor_step": step_num, "actual_step": step_num, "expected": expected, "actual": { "turn": exact_owner_match.get("turn"), "phase": exact_owner_match.get("phase"), "team_id": exact_owner_match.get("team_id"), }, } ) continue moved_to = None lo = step_num - int(drift_window) hi = step_num + int(drift_window) for candidate in known_steps: if candidate < lo or candidate > hi: continue candidate_rows = [ row for row in event_rows if row.get("step") == candidate and event_matches_filter(row, event_filter) ] if not candidate_rows: continue if any(owner_matches(row) for row in candidate_rows): moved_to = candidate break if moved_to is not None: moved_row = next( row for row in event_rows if row.get("step") == moved_to and event_matches_filter(row, event_filter) and owner_matches(row) ) reports.append( { "name": name, "status": "moved", "anchor_step": step_num, "actual_step": moved_to, "expected": expected, "actual": { "turn": moved_row.get("turn"), "phase": moved_row.get("phase"), "team_id": moved_row.get("team_id"), }, } ) else: actual_owner = None if exact_matches: row = exact_matches[0] actual_owner = { "turn": row.get("turn"), "phase": row.get("phase"), "team_id": row.get("team_id"), } reports.append( { "name": name, "status": "fail", "anchor_step": step_num, "actual_step": step_num, "expected": expected, "actual": actual_owner, } ) continue actual = step_lookup.get(step_num) if isinstance(step_num, int) else None if actual is not None and owner_matches(actual): reports.append( { "name": name, "status": "pass", "anchor_step": step_num, "actual_step": step_num, "expected": expected, "actual": actual, } ) continue moved_to = None if isinstance(step_num, int) and fingerprint: lo = step_num - int(drift_window) hi = step_num + int(drift_window) for candidate in known_steps: if candidate < lo or candidate > hi: continue candidate_owner = step_lookup.get(candidate) if not candidate_owner or not owner_matches(candidate_owner): continue candidate_fingerprint = fingerprint_builder(by_step.get(candidate, [])) if candidate_fingerprint == fingerprint: moved_to = candidate break if moved_to is not None: reports.append( { "name": name, "status": "moved", "anchor_step": step_num, "actual_step": moved_to, "expected": expected, "actual": step_lookup.get(moved_to), } ) else: reports.append( { "name": name, "status": "fail", "anchor_step": step_num, "actual_step": step_num, "expected": expected, "actual": actual, } ) return reports