#!/usr/bin/env python3 """Audit EasyCom EOT and wearer interruption/backchannel candidates. This is a read-only, TurnBench-inspired candidate audit. EasyCom does not ship human-consensus end-of-turn (EOT), interruption, or backchannel labels, so every automatically derived category is explicitly named a proxy. The script reuses the participant-resolved transcript timeline and the conservative interaction rules in :mod:`derive_easycom_interaction_events`. The primary output is a count table. With ``--output-dir`` the script also writes candidate JSONL/CSV files suitable for manual adjudication. It never modifies the source dataset. """ from __future__ import annotations import argparse import csv import json from collections import Counter from dataclasses import asdict, dataclass from pathlib import Path from typing import Sequence from derive_easycom_interaction_events import ( Event, Rules, Utterance, build_clips, classify_utterance, load_utterances, ) from easycom_manifest import ALL_SESSIONS, TEST_SESSIONS FLOOR_CLAIM_EVENT_TYPES = { "ordinary_switch", "overlap_takeover", "interruption_proxy_high", } NON_FLOOR_EVENT_TYPES = { "backchannel_proxy_high", "backchannel_candidate", "overlap_hold", "nonverbal_feedback", "nonverbal_candidate", } AMBIGUOUS_EVENT_TYPES = {"interruption_candidate"} INT_PROXY_GROUPS = { "takeover_proxy": {"overlap_takeover", "interruption_proxy_high"}, "backchannel_or_hold_proxy": { "backchannel_proxy_high", "overlap_hold", "nonverbal_feedback", }, "ambiguous_overlap_or_feedback": { "interruption_candidate", "backchannel_candidate", "nonverbal_candidate", }, } @dataclass(frozen=True) class EOTCandidate: session: str split: str category: str confidence: str holder_uid: str holder_participant_id: int holder_role: str boundary_seconds: float pause_seconds: float | None incoming_uid: str | None incoming_participant_id: int | None incoming_role: str | None incoming_start_seconds: float | None incoming_event_type: str | None reason: str def _event_map( utterances: Sequence[Utterance], *, camera_wearer_id: int, rules: Rules, ) -> dict[str, Event]: return { row.uid: classify_utterance( row, utterances, camera_wearer_id=camera_wearer_id, rules=rules, ) for row in utterances } def _next_same_speaker( holder: Utterance, utterances: Sequence[Utterance], *, epsilon: float, ) -> Utterance | None: candidates = [ row for row in utterances if row.uid != holder.uid and row.segment_id == holder.segment_id and row.participant_id == holder.participant_id and row.dense_start_seconds >= holder.dense_end_seconds - epsilon ] return min(candidates, key=lambda row: (row.dense_start_seconds, row.uid), default=None) def _same_speaker_continues_across_boundary( holder: Utterance, utterances: Sequence[Utterance], *, epsilon: float, ) -> bool: boundary = holder.dense_end_seconds return any( row.uid != holder.uid and row.segment_id == holder.segment_id and row.participant_id == holder.participant_id and row.dense_start_seconds < boundary - epsilon and row.dense_end_seconds > boundary + epsilon for row in utterances ) def _incoming_before_resume( holder: Utterance, utterances: Sequence[Utterance], *, resume: Utterance | None, epsilon: float, ) -> list[Utterance]: boundary = holder.dense_end_seconds resume_start = float("inf") if resume is None else resume.dense_start_seconds candidates = [] for row in utterances: if row.segment_id != holder.segment_id or row.participant_id == holder.participant_id: continue active_across_boundary = ( row.dense_start_seconds < boundary - epsilon and row.dense_end_seconds > boundary + epsilon ) starts_after_boundary = row.dense_start_seconds >= boundary - epsilon if not (active_across_boundary or starts_after_boundary): continue if row.dense_start_seconds > resume_start + epsilon: continue candidates.append(row) return sorted(candidates, key=lambda row: (max(boundary, row.dense_start_seconds), row.uid)) def derive_eot_candidates( utterances: Sequence[Utterance], *, camera_wearer_id: int, rules: Rules, min_pause_seconds: float = 0.1, max_pause_seconds: float = 2.0, epsilon: float = 1e-6, ) -> list[EOTCandidate]: """Derive conservative EOT positive/negative candidates. A positive proxy requires another participant to claim the floor no later than ``rules.max_switch_gap_seconds`` after the holder stops. A hard negative is a pause of ``[min_pause_seconds, max_pause_seconds]`` after which the same holder resumes with no intervening floor claim. Long gaps, terminal boundaries, annotation continuations, and disputed overlap are kept as ambiguous/excluded rather than silently becoming labels. """ if min_pause_seconds < 0 or max_pause_seconds < min_pause_seconds: raise ValueError("Require 0 <= min_pause_seconds <= max_pause_seconds") event_by_uid = _event_map( utterances, camera_wearer_id=camera_wearer_id, rules=rules, ) results: list[EOTCandidate] = [] for holder in utterances: split = "test" if holder.session in TEST_SESSIONS else "train" holder_role = "wearer" if holder.participant_id == camera_wearer_id else "other" boundary = holder.dense_end_seconds def make( category: str, confidence: str, reason: str, *, pause: float | None = None, incoming: Utterance | None = None, ) -> EOTCandidate: incoming_event = None if incoming is None else event_by_uid[incoming.uid] return EOTCandidate( session=holder.session, split=split, category=category, confidence=confidence, holder_uid=holder.uid, holder_participant_id=holder.participant_id, holder_role=holder_role, boundary_seconds=boundary, pause_seconds=pause, incoming_uid=None if incoming is None else incoming.uid, incoming_participant_id=( None if incoming is None else incoming.participant_id ), incoming_role=( None if incoming is None else ("wearer" if incoming.participant_id == camera_wearer_id else "other") ), incoming_start_seconds=( None if incoming is None else incoming.dense_start_seconds ), incoming_event_type=( None if incoming_event is None else incoming_event.event_type ), reason=reason, ) if _same_speaker_continues_across_boundary( holder, utterances, epsilon=epsilon, ): results.append(make( "eot_excluded_internal_boundary", "excluded", "another annotation for the same speaker continues across this endpoint", )) continue resume = _next_same_speaker(holder, utterances, epsilon=epsilon) pause = None if resume is None else max(0.0, resume.dense_start_seconds - boundary) if ( resume is not None and pause <= min_pause_seconds + epsilon and (resume.is_cross_clip_continuation or holder.ends_at_clip_boundary) ): results.append(make( "eot_excluded_file_continuation", "excluded", "same-speaker activity continues across an adjacent file boundary", pause=pause, incoming=resume, )) continue incoming_rows = _incoming_before_resume( holder, utterances, resume=resume, epsilon=epsilon, ) floor_claims = [ row for row in incoming_rows if event_by_uid[row.uid].event_type in FLOOR_CLAIM_EVENT_TYPES ] ambiguous = [ row for row in incoming_rows if event_by_uid[row.uid].event_type in AMBIGUOUS_EVENT_TYPES ] unresolved = [ row for row in incoming_rows if event_by_uid[row.uid].event_type not in FLOOR_CLAIM_EVENT_TYPES | NON_FLOOR_EVENT_TYPES | AMBIGUOUS_EVENT_TYPES | {"cross_clip_continuation"} ] if floor_claims: incoming = floor_claims[0] gap = incoming.dense_start_seconds - boundary if gap <= rules.max_switch_gap_seconds + epsilon: incoming_type = event_by_uid[incoming.uid].event_type category = ( "eot_positive_overlap_takeover_proxy" if incoming.dense_start_seconds < boundary - epsilon else "eot_positive_handover_proxy" ) results.append(make( category, "timing_proxy", f"incoming {incoming_type} claims the floor within the allowed gap", pause=max(0.0, gap), incoming=incoming, )) else: results.append(make( "eot_ambiguous_long_gap_before_handover", "ambiguous", "another speaker eventually claims the floor, but only after a long gap", pause=gap, incoming=incoming, )) continue if ambiguous or unresolved: incoming = sorted( [*ambiguous, *unresolved], key=lambda row: (max(boundary, row.dense_start_seconds), row.uid), )[0] incoming_type = event_by_uid[incoming.uid].event_type gap = incoming.dense_start_seconds - boundary if incoming.dense_start_seconds < boundary - epsilon: category = "eot_ambiguous_overlap" elif gap > rules.max_switch_gap_seconds + epsilon: category = "eot_ambiguous_long_gap_before_handover" else: category = "eot_ambiguous_short_or_unresolved_incoming" results.append(make( category, "ambiguous", f"an unresolved incoming event ({incoming_type}) occurs before the holder resumes", pause=max(0.0, gap), incoming=incoming, )) continue if resume is not None: if pause < min_pause_seconds - epsilon: category = "eot_excluded_annotation_seam" confidence = "excluded" reason = "same speaker resumes too quickly to form a meaningful pause" elif pause <= max_pause_seconds + epsilon: category = "eot_negative_mid_turn_pause_proxy" confidence = "timing_proxy" reason = "same speaker resumes within the pause limit with no floor claim" else: category = "eot_ambiguous_long_same_speaker_gap" confidence = "ambiguous" reason = "same speaker resumes after a long gap; turn continuity is unknown" results.append(make( category, confidence, reason, pause=pause, incoming=resume, )) continue # TurnBench counts a speaker's final turn as EOT. We deliberately do # not: EasyCom clips and redacted source gaps make the endpoint # semantically unknown without manual review. results.append(make( "eot_excluded_terminal_or_gap", "excluded", "no later same-speaker or floor-claim evidence in this contiguous segment", )) return results def _serialize_event(event: Event) -> dict: payload = asdict(event) payload["target_of_speech"] = list(event.target_of_speech) return payload def _write_rows(path: Path, rows: list[dict]) -> None: path.parent.mkdir(parents=True, exist_ok=True) if path.suffix == ".jsonl": with path.open("w", encoding="utf-8") as handle: for row in rows: handle.write(json.dumps(row, ensure_ascii=False) + "\n") return fieldnames = list(rows[0]) if rows else [] with path.open("w", encoding="utf-8", newline="") as handle: writer = csv.DictWriter(handle, fieldnames=fieldnames) if fieldnames: writer.writeheader() for row in rows: encoded = { key: json.dumps(value, ensure_ascii=False) if isinstance(value, (list, tuple, dict)) else value for key, value in row.items() } writer.writerow(encoded) def _print_counts(title: str, counts: Counter) -> None: print(title) for name, count in sorted(counts.items()): print(f" {name:<44} {count:>6}") def parse_args() -> argparse.Namespace: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--dataset-root", type=Path, required=True) parser.add_argument("--sessions", nargs="+", default=list(ALL_SESSIONS)) parser.add_argument("--camera-wearer-id", type=int, default=2) parser.add_argument("--output-dir", type=Path) parser.add_argument("--min-pause-seconds", type=float, default=0.1) parser.add_argument("--max-pause-seconds", type=float, default=2.0) parser.add_argument("--context-seconds", type=float, default=1.5) parser.add_argument("--backchannel-max-seconds", type=float, default=1.0) parser.add_argument("--max-switch-gap-seconds", type=float, default=0.6) parser.add_argument("--min-overlap-seconds", type=float, default=0.1) parser.add_argument("--takeover-candidate-seconds", type=float, default=0.4) parser.add_argument("--takeover-high-seconds", type=float, default=0.8) parser.add_argument("--max-contiguous-source-gap-seconds", type=float, default=1.0) return parser.parse_args() def main() -> None: args = parse_args() unknown = sorted(set(args.sessions) - set(ALL_SESSIONS)) if unknown: raise ValueError(f"Unknown EasyCom sessions: {unknown}") rules = Rules( context_seconds=args.context_seconds, backchannel_max_seconds=args.backchannel_max_seconds, max_switch_gap_seconds=args.max_switch_gap_seconds, min_overlap_seconds=args.min_overlap_seconds, takeover_candidate_seconds=args.takeover_candidate_seconds, takeover_high_seconds=args.takeover_high_seconds, max_contiguous_source_gap_seconds=args.max_contiguous_source_gap_seconds, ) all_eot: list[EOTCandidate] = [] all_events: list[Event] = [] for session in args.sessions: clips = build_clips(args.dataset_root.resolve(), session, rules) utterances = load_utterances(session, clips) events = list(_event_map( utterances, camera_wearer_id=args.camera_wearer_id, rules=rules, ).values()) eot = derive_eot_candidates( utterances, camera_wearer_id=args.camera_wearer_id, rules=rules, min_pause_seconds=args.min_pause_seconds, max_pause_seconds=args.max_pause_seconds, ) all_events.extend(events) all_eot.extend(eot) print(f"{session}: utterances={len(utterances)} eot_boundaries={len(eot)}") for split in ("train", "test", "all"): selected_eot = [row for row in all_eot if split == "all" or row.split == split] selected_events = [row for row in all_events if split == "all" or row.split == split] print(f"\n[{split}]") _print_counts( "Other-floor EOT candidates", Counter(row.category for row in selected_eot if row.holder_role == "other"), ) _print_counts( "All-holder EOT candidates", Counter(row.category for row in selected_eot), ) int_counts = Counter() for event in selected_events: if event.participant_role != "wearer": continue for group, names in INT_PROXY_GROUPS.items(): if event.event_type in names: int_counts[group] += 1 int_counts[f"{group}/{event.event_type}"] += 1 break _print_counts("Wearer takeover/backchannel candidates", int_counts) if args.output_dir is not None: output = args.output_dir.resolve() eot_rows = [ asdict(row) for row in all_eot if row.holder_role == "other" and not row.category.startswith("eot_excluded_") ] int_rows = [] candidate_names = set().union(*INT_PROXY_GROUPS.values()) for event in all_events: if event.participant_role == "wearer" and event.event_type in candidate_names: row = _serialize_event(event) row["proxy_group"] = next( group for group, names in INT_PROXY_GROUPS.items() if event.event_type in names ) int_rows.append(row) _write_rows(output / "easycom_eot_candidates.jsonl", eot_rows) _write_rows(output / "easycom_eot_candidates.csv", eot_rows) _write_rows(output / "easycom_wearer_int_candidates.jsonl", int_rows) _write_rows(output / "easycom_wearer_int_candidates.csv", int_rows) split_summaries = {} candidate_names = set().union(*INT_PROXY_GROUPS.values()) for split in ("train", "test", "all"): selected_eot = [ row for row in all_eot if split == "all" or row.split == split ] selected_events = [ event for event in all_events if split == "all" or event.split == split ] proxy_groups = Counter() for event in selected_events: if event.participant_role != "wearer": continue for group, names in INT_PROXY_GROUPS.items(): if event.event_type in names: proxy_groups[group] += 1 break split_summaries[split] = { "other_floor_eot_counts": dict(sorted(Counter( row.category for row in selected_eot if row.holder_role == "other" ).items())), "wearer_proxy_group_counts": dict(sorted(proxy_groups.items())), "wearer_event_type_counts": dict(sorted(Counter( event.event_type for event in selected_events if event.participant_role == "wearer" and event.event_type in candidate_names ).items())), } summary = { "schema_version": 1, "dataset": "EasyCom", "semantic_warning": ( "All EOT, takeover, interruption, and backchannel categories are " "timing/transcript-derived proxies, not human-consensus labels." ), "sessions": args.sessions, "rules": asdict(rules), "min_pause_seconds": args.min_pause_seconds, "max_pause_seconds": args.max_pause_seconds, "splits": split_summaries, } output.mkdir(parents=True, exist_ok=True) (output / "summary.json").write_text( json.dumps(summary, indent=2, ensure_ascii=False) + "\n", encoding="utf-8", ) print(f"\nSaved proxy audit to {output}") if __name__ == "__main__": main()