Add EasyCom interaction weak-label pipeline

This commit is contained in:
Xin Wang
2026-08-28 00:07:54 +08:00
parent d34725778f
commit 5f45e7c115
5 changed files with 1661 additions and 0 deletions

117
README.md
View File

@@ -1658,6 +1658,123 @@ semantics. Run the following controlled experiments:
The frozen-Omni head is the cleanest representation test. LoRA can be reported The frozen-Omni head is the cleanest representation test. LoRA can be reported
as a second-stage ceiling, not as the only result. as a second-stage ceiling, not as the only result.
## Transcript-derived interaction-event proxies
EasyCom's released human transcriptions contain inclusive 20 FPS
`Start_Frame`/`End_Frame`, `Participant_ID`, `Transcription`, and
`Target_of_Speech`. They do **not** contain a semantic backchannel class.
[`derive_easycom_interaction_events.py`](derive_easycom_interaction_events.py)
therefore separates observed timing relations from weak semantic proxies:
- `ordinary_switch`: a non-overlapping recent speaker change followed by a
sustained incoming turn;
- `overlap_takeover`: an observed overlapping onset followed by sustained
takeover, without claiming interruption intent;
- `overlap_hold`: overlap without enough evidence for takeover;
- `backchannel_proxy_high`: a conservative short lexical response while the
same other participant dominates immediately before and after it;
- `interruption_proxy_high`: the previous transcript has a non-boundary
trailing cutoff and the incoming participant takes over;
- `nonverbal_feedback`: laughter-only feedback around another participant's
floor; pure `[H]`, `[C]`, `[T]`, and `[U]` records are ambiguous candidates.
`backchannel_candidate`, `interruption_candidate`, and
`nonverbal_candidate` are retained for auditing but masked from auxiliary
training. `[X]` continuations across adjacent one-minute files are not new
events. Source-time gaps larger than one second create separate segments, so
the dense EgoSpeak concatenation does not create a false switch across a
redacted interval. A one- or two-frame annotation/media endpoint discrepancy
is clamped and reported; larger disagreement is fatal.
A read-only audit on the raw-data machine is:
```bash
cd /mnt/58462F4C462F29E8/Code/icassp2027-experiment
python3 derive_easycom_interaction_events.py \
--dataset-root /mnt/58462F4C462F29E8/Datasets/easycom \
--target-root /mnt/58462F4C462F29E8/Code/EgoSpeak/dataset/EasyCom/target_perframe \
--examples-per-event 10
```
To persist JSONL/CSV reports outside the dataset, add:
```bash
--output-dir reports/easycom_interaction_events_v1 \
--overwrite
```
In an environment with NumPy, add `--write-frame-arrays` to also write one
`Session_*.aux_targets.npz` per session. It contains 5 Hz `event_onsets`,
`event_roles`, collision flags, and event-name metadata aligned to
`target_perframe`. The raw EasyCom tree is never modified.
The initial all-session audit found 6,981 utterances, including 530
high-confidence backchannel proxies, 78 high-confidence interruption proxies,
937 overlap-takeovers, 686 overlap-holds, and 349 laughter-feedback events.
These counts establish usable weak-supervision coverage, not label accuracy.
Before training, inspect a stratified sample from Sessions 4--12 and freeze the
rules; do not tune thresholds on test Sessions 1--3. The primary evaluator
remains the original EgoSpeak three-label AP/mAP protocol.
### Optional DeepSeek adjudication of ambiguous candidates
DeepSeek is not required for the deterministic timing labels above. It is an
optional second-stage weak-label adjudicator for only
`backchannel_candidate` and `interruption_candidate`; sending all 6,981
utterances to an LLM would add cost and noise without adding observable
prosody, gaze, or intent. The current audit contains 614 such candidates over
all sessions and 497 in the training split (Sessions 4--12).
[`adjudicate_easycom_interaction_events.py`](adjudicate_easycom_interaction_events.py)
uses DeepSeek JSON Output through the standard-library HTTP client, so no
additional Python package is required. It makes two differently instructed
conservative passes. A candidate is promoted only if both passes return the candidate's
matching semantic label with high confidence; disagreements, medium/low
confidence, short answers, cooperative overlaps, and uncertain cases remain
masked. The result is still a weak proxy and must not be presented as human
ground truth. The append-only JSONL records prompt/model metadata and resumes
completed candidates, while rejecting a mixed model or prompt configuration.
First inspect exactly what would leave the machine. This makes no API call and
does not require a key:
```bash
python3 adjudicate_easycom_interaction_events.py \
--input-dir reports/easycom_interaction_events_v1 \
--output reports/easycom_interaction_llm_v1.jsonl \
--max-events 16 \
--dry-run
```
If external transcript upload is authorized, enter the key without placing it
in shell history and run a 32-candidate pilot:
```bash
read -s -p "DeepSeek API key: " DEEPSEEK_API_KEY; echo
export DEEPSEEK_API_KEY
python3 adjudicate_easycom_interaction_events.py \
--input-dir reports/easycom_interaction_events_v1 \
--output reports/easycom_interaction_llm_pilot_v1.jsonl \
--max-events 32 \
--selection-seed 2027 \
--batch-size 8 \
--passes 2 \
--allow-external-transcript-upload
unset DEEPSEEK_API_KEY
```
Audit the pilot rationales and precision manually before running the full
training split. For the full pass, use a new output path, remove
`--max-events`, and keep the default `--splits train`. The default model is
`deepseek-v4-flash`; it can be changed with `--model` or `DEEPSEEK_MODEL` as
the provider's available model names evolve. Never tune the prompt or
acceptance rule using Sessions 1--3. LLM-adjudicated candidates are deliberately
kept in a separate JSONL until pilot precision is accepted; they are not yet
silently merged into the training arrays.
## Candidate improvement: explicit working and long memory ## Candidate improvement: explicit working and long memory
Motivated by EgoSpeak's separation of long-term and working memory, test an Motivated by EgoSpeak's separation of long-term and working memory, test an

View File

@@ -0,0 +1,461 @@
#!/usr/bin/env python3
"""Optionally adjudicate ambiguous EasyCom interaction proxies with DeepSeek.
This is a second-stage *weak-label adjudicator*, not a replacement for the
timestamp rules in ``derive_easycom_interaction_events.py``. By default it
only reviews ambiguous training candidates and promotes a semantic label when
two passes agree and both report high confidence.
Transcript excerpts are sent to an external API only when the caller passes
``--allow-external-transcript-upload``. The API key is read exclusively from
``DEEPSEEK_API_KEY`` and is never written to output.
"""
from __future__ import annotations
import argparse
import hashlib
import json
import os
import random
import time
import urllib.error
import urllib.request
from collections import Counter, defaultdict
from pathlib import Path
from typing import Any, Iterable, Sequence
PROMPT_VERSION = "easycom-interaction-adjudicator-v1"
OUTPUT_SCHEMA_VERSION = 1
DEFAULT_BASE_URL = "https://api.deepseek.com"
DEFAULT_MODEL = "deepseek-v4-flash"
DEFAULT_EVENT_TYPES = ("backchannel_candidate", "interruption_candidate")
ALLOWED_LABELS = {
"backchannel",
"short_answer_or_response",
"interruption",
"cooperative_overlap_takeover",
"other",
"uncertain",
}
ALLOWED_CONFIDENCE = {"high", "medium", "low"}
SYSTEM_PROMPT = """You are adjudicating weak labels for conversational turn-taking research.
Use only the supplied transcript and timing evidence. Do not infer prosody, gaze, or intention that is not observed.
Definitions:
- backchannel: a brief acknowledgement/feedback that does not take the conversational floor.
- short_answer_or_response: a brief but content-bearing answer/response, not merely feedback.
- interruption: the incoming speaker competitively cuts off the previous floor holder and takes the floor.
- cooperative_overlap_takeover: overlapping floor transfer without enough evidence of competitive interruption.
- other: none of the above.
- uncertain: the available text/timing cannot reliably distinguish the labels.
Be conservative. Timing overlap alone does not prove interruption. A short word alone does not prove backchannel.
Return one JSON object exactly in this form:
{"items":[{"uid":"...","label":"uncertain","confidence":"low","rationale":"brief evidence-based sentence"}]}
Return every input uid exactly once. The response must be JSON, with no markdown or extra text."""
def _load_jsonl(path: Path) -> list[dict[str, Any]]:
rows: list[dict[str, Any]] = []
with path.open("r", encoding="utf-8") as handle:
for line_number, line in enumerate(handle, start=1):
line = line.strip()
if not line:
continue
try:
row = json.loads(line)
except json.JSONDecodeError as exc:
raise ValueError(f"Invalid JSON at {path}:{line_number}: {exc}") from exc
if not isinstance(row, dict) or not isinstance(row.get("uid"), str):
raise ValueError(f"Expected object with uid at {path}:{line_number}")
rows.append(row)
return rows
def load_candidates(
input_dir: Path,
*,
splits: set[str],
event_types: set[str],
) -> list[dict[str, Any]]:
paths = sorted(input_dir.glob("Session_*.events.jsonl"))
if not paths:
raise FileNotFoundError(f"No Session_*.events.jsonl files in {input_dir}")
candidates: list[dict[str, Any]] = []
seen: set[str] = set()
for path in paths:
for row in _load_jsonl(path):
uid = row["uid"]
if uid in seen:
raise ValueError(f"Duplicate event uid: {uid}")
seen.add(uid)
if row.get("split") in splits and row.get("event_type") in event_types:
candidates.append(row)
return sorted(candidates, key=lambda row: row["uid"])
def stratified_limit(
rows: Sequence[dict[str, Any]],
limit: int | None,
*,
seed: int,
) -> list[dict[str, Any]]:
"""Select a deterministic pilot across sessions and candidate types."""
if limit is None or limit >= len(rows):
return list(rows)
if limit < 0:
raise ValueError("--max-events must be non-negative")
rng = random.Random(seed)
buckets: dict[tuple[str, str], list[dict[str, Any]]] = defaultdict(list)
for row in rows:
buckets[(str(row.get("event_type")), str(row.get("session")))].append(row)
keys = sorted(buckets)
for key in keys:
rng.shuffle(buckets[key])
rng.shuffle(keys)
selected: list[dict[str, Any]] = []
while len(selected) < limit:
made_progress = False
for key in keys:
if buckets[key]:
selected.append(buckets[key].pop())
made_progress = True
if len(selected) == limit:
break
if not made_progress:
break
return sorted(selected, key=lambda row: row["uid"])
def _candidate_for_prompt(row: dict[str, Any]) -> dict[str, Any]:
fields = (
"uid",
"event_type",
"participant_role",
"participant_id",
"previous_floor_participant_id",
"retained_floor_participant_id",
"target_matches_previous_floor",
"duration_seconds",
"onset_overlap_seconds",
"gap_from_previous_seconds",
"speaker_after_previous_end_seconds",
"previous_transcript_has_cutoff",
"previous_transcription",
"transcription",
"target_of_speech",
)
return {field: row.get(field) for field in fields}
def build_user_prompt(rows: Sequence[dict[str, Any]], pass_index: int) -> str:
if pass_index == 0:
review = "Primary pass: classify conservatively from the supplied evidence."
else:
review = (
"Skeptical review pass: actively look for alternative explanations. "
"Use uncertain unless the semantic label remains well supported."
)
payload = [_candidate_for_prompt(row) for row in rows]
return (
review
+ "\nReturn JSON for these candidates:\n"
+ json.dumps(payload, ensure_ascii=False, separators=(",", ":"))
)
def prompt_hash(rows: Sequence[dict[str, Any]], passes: int) -> str:
payload = {
"version": PROMPT_VERSION,
"passes": passes,
"system": SYSTEM_PROMPT,
"rows": [_candidate_for_prompt(row) for row in rows],
}
encoded = json.dumps(payload, sort_keys=True, ensure_ascii=False).encode("utf-8")
return hashlib.sha256(encoded).hexdigest()
def _validate_decisions(
payload: Any,
expected_uids: Sequence[str],
) -> list[dict[str, str]]:
if not isinstance(payload, dict) or not isinstance(payload.get("items"), list):
raise ValueError("Response must be a JSON object containing an items list")
expected = set(expected_uids)
actual: dict[str, dict[str, str]] = {}
for item in payload["items"]:
if not isinstance(item, dict):
raise ValueError("Every response item must be an object")
uid = item.get("uid")
label = item.get("label")
confidence = item.get("confidence")
rationale = item.get("rationale")
if uid not in expected or uid in actual:
raise ValueError(f"Unexpected or duplicate uid in response: {uid!r}")
if label not in ALLOWED_LABELS:
raise ValueError(f"Invalid label for {uid}: {label!r}")
if confidence not in ALLOWED_CONFIDENCE:
raise ValueError(f"Invalid confidence for {uid}: {confidence!r}")
if not isinstance(rationale, str) or not rationale.strip():
raise ValueError(f"Missing rationale for {uid}")
actual[uid] = {
"uid": uid,
"label": label,
"confidence": confidence,
"rationale": rationale.strip(),
}
if set(actual) != expected:
raise ValueError(f"Response uid mismatch; missing={sorted(expected-set(actual))}")
return [actual[uid] for uid in expected_uids]
def _post_chat_completion(
*,
api_key: str,
base_url: str,
model: str,
user_prompt: str,
timeout_seconds: float,
max_retries: int,
) -> tuple[dict[str, Any], dict[str, Any]]:
endpoint = base_url.rstrip("/") + "/chat/completions"
body = {
"model": model,
"messages": [
{"role": "system", "content": SYSTEM_PROMPT},
{"role": "user", "content": user_prompt},
],
"response_format": {"type": "json_object"},
"thinking": {"type": "disabled"},
"temperature": 0,
"max_tokens": 2048,
"stream": False,
}
request = urllib.request.Request(
endpoint,
data=json.dumps(body, ensure_ascii=False).encode("utf-8"),
headers={
"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json",
},
method="POST",
)
last_error: Exception | None = None
for attempt in range(max_retries + 1):
try:
with urllib.request.urlopen(request, timeout=timeout_seconds) as response:
envelope = json.loads(response.read().decode("utf-8"))
content = envelope["choices"][0]["message"]["content"]
if not isinstance(content, str) or not content.strip():
raise ValueError("DeepSeek returned empty JSON content")
return json.loads(content), envelope.get("usage", {})
except (urllib.error.HTTPError, urllib.error.URLError, TimeoutError, KeyError, json.JSONDecodeError, ValueError) as exc:
last_error = exc
if attempt >= max_retries:
break
time.sleep(min(30.0, 2.0**attempt))
raise RuntimeError(f"DeepSeek request failed after {max_retries + 1} attempts: {last_error}")
def adjudicate_batch(
rows: Sequence[dict[str, Any]],
*,
api_key: str,
base_url: str,
model: str,
passes: int,
timeout_seconds: float,
max_retries: int,
) -> tuple[list[dict[str, Any]], Counter[str]]:
expected_uids = [row["uid"] for row in rows]
by_uid: dict[str, list[dict[str, str]]] = {uid: [] for uid in expected_uids}
usage: Counter[str] = Counter()
for pass_index in range(passes):
payload, pass_usage = _post_chat_completion(
api_key=api_key,
base_url=base_url,
model=model,
user_prompt=build_user_prompt(rows, pass_index),
timeout_seconds=timeout_seconds,
max_retries=max_retries,
)
for key, value in pass_usage.items():
if isinstance(value, int):
usage[key] += value
for decision in _validate_decisions(payload, expected_uids):
by_uid[decision["uid"]].append(decision)
request_prompt_sha256 = prompt_hash(rows, passes)
results: list[dict[str, Any]] = []
for source in rows:
decisions = by_uid[source["uid"]]
labels = {decision["label"] for decision in decisions}
unanimous = len(labels) == 1
final_label = decisions[0]["label"] if unanimous else "uncertain"
all_high = all(decision["confidence"] == "high" for decision in decisions)
expected_label = {
"backchannel_candidate": "backchannel",
"interruption_candidate": "interruption",
}.get(source.get("event_type"))
accepted = unanimous and all_high and final_label == expected_label
promoted = (
f"{final_label}_proxy_llm_consensus" if accepted else None
)
results.append(
{
"schema_version": OUTPUT_SCHEMA_VERSION,
"uid": source["uid"],
"session": source.get("session"),
"split": source.get("split"),
"source_event_type": source.get("event_type"),
"model": model,
"prompt_version": PROMPT_VERSION,
"request_prompt_sha256": request_prompt_sha256,
"passes": passes,
"decisions": decisions,
"unanimous": unanimous,
"final_label": final_label,
"all_high_confidence": all_high,
"accepted_for_auxiliary": accepted,
"promoted_event_type": promoted,
}
)
return results, usage
def _validate_resume_rows(
rows: Sequence[dict[str, Any]],
*,
model: str,
passes: int,
) -> None:
"""Prevent an append-only output from silently mixing experiments."""
seen: set[str] = set()
for row in rows:
uid = row["uid"]
if uid in seen:
raise ValueError(f"Duplicate uid in existing output: {uid}")
seen.add(uid)
expected = {
"schema_version": OUTPUT_SCHEMA_VERSION,
"model": model,
"prompt_version": PROMPT_VERSION,
"passes": passes,
}
mismatches = {
key: (row.get(key), value)
for key, value in expected.items()
if row.get(key) != value
}
if mismatches:
raise ValueError(
f"Existing output {uid} was produced by a different adjudication "
f"configuration: {mismatches}. Use a new --output path."
)
def _chunks(rows: Sequence[dict[str, Any]], size: int) -> Iterable[Sequence[dict[str, Any]]]:
for start in range(0, len(rows), size):
yield rows[start : start + size]
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--input-dir", type=Path, required=True)
parser.add_argument("--output", type=Path, required=True)
parser.add_argument("--splits", nargs="+", default=["train"])
parser.add_argument("--event-types", nargs="+", default=list(DEFAULT_EVENT_TYPES))
parser.add_argument("--model", default=os.environ.get("DEEPSEEK_MODEL", DEFAULT_MODEL))
parser.add_argument("--base-url", default=os.environ.get("DEEPSEEK_BASE_URL", DEFAULT_BASE_URL))
parser.add_argument("--batch-size", type=int, default=8)
parser.add_argument("--passes", type=int, default=2)
parser.add_argument("--max-events", type=int)
parser.add_argument("--selection-seed", type=int, default=2027)
parser.add_argument("--timeout-seconds", type=float, default=120.0)
parser.add_argument("--max-retries", type=int, default=3)
parser.add_argument("--dry-run", action="store_true")
parser.add_argument("--allow-external-transcript-upload", action="store_true")
return parser.parse_args()
def main() -> None:
args = parse_args()
if args.batch_size <= 0 or args.passes <= 0 or args.max_retries < 0:
raise ValueError("batch-size/passes must be positive and max-retries non-negative")
candidates = load_candidates(
args.input_dir.resolve(),
splits=set(args.splits),
event_types=set(args.event_types),
)
candidates = stratified_limit(
candidates,
args.max_events,
seed=args.selection_seed,
)
completed: set[str] = set()
if args.output.exists():
resume_rows = _load_jsonl(args.output)
_validate_resume_rows(resume_rows, model=args.model, passes=args.passes)
completed = {row["uid"] for row in resume_rows}
pending = [row for row in candidates if row["uid"] not in completed]
print(
f"Candidates={len(candidates)} completed={len(completed & {row['uid'] for row in candidates})} "
f"pending={len(pending)} model={args.model} passes={args.passes}"
)
if not pending:
return
if args.dry_run:
sample = pending[: args.batch_size]
print("DRY RUN: no API request will be made. First request prompt:\n")
print(build_user_prompt(sample, 0))
print("\nprompt_sha256:", prompt_hash(sample, args.passes))
return
if not args.allow_external_transcript_upload:
raise PermissionError(
"Refusing to upload transcript excerpts. Review --dry-run, then pass "
"--allow-external-transcript-upload if authorized."
)
api_key = os.environ.get("DEEPSEEK_API_KEY")
if not api_key:
raise RuntimeError("Set DEEPSEEK_API_KEY in the environment")
args.output.parent.mkdir(parents=True, exist_ok=True)
usage: Counter[str] = Counter()
accepted: Counter[str] = Counter()
processed = 0
with args.output.open("a", encoding="utf-8") as handle:
for batch_number, batch in enumerate(_chunks(pending, args.batch_size), start=1):
results, batch_usage = adjudicate_batch(
batch,
api_key=api_key,
base_url=args.base_url,
model=args.model,
passes=args.passes,
timeout_seconds=args.timeout_seconds,
max_retries=args.max_retries,
)
usage.update(batch_usage)
for result in results:
handle.write(json.dumps(result, ensure_ascii=False) + "\n")
if result["accepted_for_auxiliary"]:
accepted[result["final_label"]] += 1
handle.flush()
processed += len(results)
print(
f"batch={batch_number} processed={processed}/{len(pending)} "
f"accepted={dict(accepted)} total_tokens={usage.get('total_tokens', 0)}"
)
print(f"Saved adjudications to {args.output.resolve()}")
print("Usage:", dict(usage))
print("Accepted high-confidence consensus:", dict(accepted))
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,874 @@
#!/usr/bin/env python3
"""Derive auditable EasyCom interaction-event proxies from transcripts.
The released EasyCom transcription JSON files provide participant-resolved
``Start_Frame``/``End_Frame`` intervals, text, and ``Target_of_Speech``. They
do *not* provide a semantic backchannel label. This script therefore keeps
deterministic timing events separate from high-precision weak proxies:
* ordinary speaker switch;
* backchannel proxy (short feedback while another participant retains floor);
* interruption proxy (overlapping onset followed by floor takeover);
* overlap without takeover;
* non-verbal feedback; and
* ambiguous candidates, which should be masked from auxiliary training.
The source dataset is never modified. With ``--output-dir`` the script writes
JSONL/CSV reports and optional 5 Hz ``.npz`` onset arrays to a separate path.
Without it, the command is a read-only audit.
"""
from __future__ import annotations
import argparse
import csv
import json
import re
import ast
import struct
from collections import Counter, defaultdict
from dataclasses import asdict, dataclass
from pathlib import Path
from typing import Any, Sequence
from easycom_manifest import ALL_SESSIONS, TEST_SESSIONS, parse_timecode, probe_video
SOURCE_FPS = 20
TASK_FPS = 5
CAMERA_WEARER_ID = 2
FRAMES_PER_TASK_STEP = SOURCE_FPS // TASK_FPS
AUXILIARY_EVENT_NAMES = (
"ordinary_switch",
"backchannel_proxy_high",
"overlap_takeover",
"interruption_proxy_high",
"overlap_hold",
"nonverbal_feedback",
)
AMBIGUOUS_EVENT_NAMES = (
"backchannel_candidate",
"interruption_candidate",
"nonverbal_candidate",
)
MARKER_RE = re.compile(r"\[[A-Za-z]\]")
WORD_RE = re.compile(r"[a-z]+(?:'[a-z]+)?")
TRAILING_CUTOFF_RE = re.compile(r"-\s*(?:\[[A-Za-z]\]\s*)*$")
PURE_NONVERBAL_RE = re.compile(r"^(?:\s*\[(?:L|C|T|H|U)\]\s*[.!?]?\s*)+$")
# Deliberately conservative. A lexical match is not sufficient: timing must
# also show that another participant retained the floor.
FEEDBACK_PHRASES = {
"alright",
"awesome",
"cool",
"definitely",
"exactly",
"fair",
"good",
"got it",
"great",
"huh",
"i know",
"i see",
"interesting",
"makes sense",
"mhm",
"mm",
"mm hmm",
"nice",
"no",
"no way",
"nope",
"oh",
"oh man",
"okay",
"really",
"right",
"sure",
"true",
"uh huh",
"uh-huh",
"wow",
"yeah",
"yep",
"yes",
"yup",
}
FEEDBACK_TOKENS = {
token for phrase in FEEDBACK_PHRASES for token in WORD_RE.findall(phrase)
}
@dataclass(frozen=True)
class Clip:
path: str
transcript_path: str
stem: str
source_start_seconds: float
source_end_seconds: float
dense_start_frame: int
num_frames: int
segment_id: int
discontinuity_before_seconds: float | None
@property
def dense_start_seconds(self) -> float:
return self.dense_start_frame / SOURCE_FPS
@dataclass(frozen=True)
class Utterance:
uid: str
session: str
clip_stem: str
clip_index: int
segment_id: int
participant_id: int
target_of_speech: tuple[int | str, ...]
transcription: str
source_start_frame: int
source_end_frame: int
dense_start_frame: int
dense_end_frame_exclusive: int
dense_start_seconds: float
dense_end_seconds: float
duration_seconds: float
starts_at_clip_boundary: bool
ends_at_clip_boundary: bool
annotation_end_frame_clamped_by: int
is_cross_clip_continuation: bool
@dataclass(frozen=True)
class Event:
uid: str
session: str
split: str
event_type: str
confidence: str
trainable_auxiliary: bool
participant_id: int
participant_role: str
previous_floor_participant_id: int | None
retained_floor_participant_id: int | None
target_matches_previous_floor: bool
clip_stem: str
source_start_frame: int
dense_start_seconds: float
task_step_index: int
duration_seconds: float
onset_overlap_seconds: float
gap_from_previous_seconds: float | None
speaker_after_previous_end_seconds: float
previous_transcript_has_cutoff: bool
previous_transcription: str | None
lexical_feedback: bool
pure_nonverbal: bool
transcription: str
target_of_speech: tuple[int | str, ...]
reason: str
@dataclass(frozen=True)
class Rules:
context_seconds: float = 1.5
backchannel_max_seconds: float = 1.0
max_switch_gap_seconds: float = 0.6
min_overlap_seconds: float = 0.1
takeover_candidate_seconds: float = 0.4
takeover_high_seconds: float = 0.8
max_contiguous_source_gap_seconds: float = 1.0
def _load_json(path: Path) -> Any:
payload = path.read_bytes()
errors: list[str] = []
for encoding in ("utf-8", "cp1252", "latin1"):
try:
return json.loads(payload.decode(encoding))
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
errors.append(f"{encoding}: {exc}")
raise ValueError(f"Could not decode {path}: {'; '.join(errors)}")
def _normalized_words(text: str) -> tuple[str, ...]:
return tuple(WORD_RE.findall(MARKER_RE.sub(" ", text).lower()))
def is_lexical_feedback(text: str) -> bool:
if "?" in text:
return False
words = _normalized_words(text)
if not words or len(words) > 5:
return False
phrase = " ".join(words)
return phrase in FEEDBACK_PHRASES or all(word in FEEDBACK_TOKENS for word in words)
def is_pure_nonverbal(text: str) -> bool:
return PURE_NONVERBAL_RE.fullmatch(text.strip()) is not None
def has_trailing_cutoff(utterance: Utterance) -> bool:
# [X] at a one-minute boundary denotes a continued/truncated transcript,
# not evidence that another participant interrupted the speaker.
return (
TRAILING_CUTOFF_RE.search(utterance.transcription.strip()) is not None
and "[X]" not in utterance.transcription.upper()
and not utterance.ends_at_clip_boundary
)
def _target_values(value: Any) -> tuple[int | str, ...]:
values = value if isinstance(value, list) else [value]
normalized: list[int | str] = []
for item in values:
if isinstance(item, bool):
normalized.append(str(item))
elif isinstance(item, int):
normalized.append(item)
elif isinstance(item, float) and item.is_integer():
normalized.append(int(item))
else:
normalized.append(str(item))
return tuple(normalized)
def _target_length(path: Path | None) -> int | None:
if path is None:
return None
# Read only the NPY header so an audit does not require NumPy on the raw
# data host. NumPy is required only when --write-frame-arrays is requested.
with path.open("rb") as handle:
if handle.read(6) != bytes([147]) + b"NUMPY":
raise ValueError(f"Not a NumPy array: {path}")
major, _minor = handle.read(2)
length_size = 2 if major == 1 else 4
header_length = struct.unpack(
"<H" if length_size == 2 else "<I",
handle.read(length_size),
)[0]
header = ast.literal_eval(handle.read(header_length).decode("latin1").strip())
shape = header.get("shape")
if not isinstance(shape, tuple) or len(shape) != 2 or shape[1] != 3:
raise ValueError(f"Expected target [T,3], got {shape}: {path}")
return int(shape[0])
def build_clips(dataset_root: Path, session: str, rules: Rules) -> list[Clip]:
video_dir = dataset_root / "Main" / "Video_Compressed" / session
transcript_dir = dataset_root / "Main" / "Speech_Transcriptions" / session
if not video_dir.is_dir():
raise FileNotFoundError(f"Missing video directory: {video_dir}")
if not transcript_dir.is_dir():
raise FileNotFoundError(f"Missing transcript directory: {transcript_dir}")
video_paths = sorted(video_dir.glob("*.mp4"), key=lambda p: parse_timecode(p.stem))
transcript_paths = {path.stem: path for path in transcript_dir.glob("*.json")}
video_stems = {path.stem for path in video_paths}
if video_stems != set(transcript_paths):
missing_json = sorted(video_stems - set(transcript_paths))
missing_video = sorted(set(transcript_paths) - video_stems)
raise ValueError(
f"{session} video/transcript inventory differs; "
f"missing_json={missing_json}, missing_video={missing_video}"
)
clips: list[Clip] = []
dense_start_frame = 0
previous_source_end: float | None = None
segment_id = 0
for path in video_paths:
media = probe_video(path)
frames = int(media["frames_at_20hz"])
source_start = parse_timecode(path.stem)
source_end = source_start + frames / SOURCE_FPS
delta = None if previous_source_end is None else source_start - previous_source_end
if delta is not None and abs(delta) > rules.max_contiguous_source_gap_seconds:
segment_id += 1
clips.append(
Clip(
path=str(path.resolve()),
transcript_path=str(transcript_paths[path.stem].resolve()),
stem=path.stem,
source_start_seconds=source_start,
source_end_seconds=source_end,
dense_start_frame=dense_start_frame,
num_frames=frames,
segment_id=segment_id,
discontinuity_before_seconds=delta,
)
)
dense_start_frame += frames
previous_source_end = source_end
return clips
def load_utterances(session: str, clips: Sequence[Clip]) -> list[Utterance]:
utterances: list[Utterance] = []
per_clip: list[list[Utterance]] = []
for clip_index, clip in enumerate(clips):
payload = _load_json(Path(clip.transcript_path))
if not isinstance(payload, list):
raise ValueError(f"Expected a list in {clip.transcript_path}")
clip_rows: list[Utterance] = []
for row_index, row in enumerate(payload):
required = {
"Start_Frame",
"End_Frame",
"Participant_ID",
"Transcription",
"Target_of_Speech",
}
if not isinstance(row, dict) or not required.issubset(row):
raise ValueError(
f"Invalid transcription row {row_index} in {clip.transcript_path}: {row}"
)
start = int(row["Start_Frame"])
annotation_end = int(row["End_Frame"])
end_overrun = max(0, annotation_end - clip.num_frames)
# A few released MP4/container frame counts differ from the JSON
# endpoint by one frame. Permit only a tightly bounded clamp and
# expose it in the audit report; larger disagreements remain fatal.
if end_overrun > 2:
raise ValueError(
f"Annotation endpoint exceeds media by {end_overrun} frames in "
f"{clip.transcript_path}: End_Frame={annotation_end}, "
f"clip_frames={clip.num_frames}"
)
end = min(annotation_end, clip.num_frames)
if start < 1 or end < start:
raise ValueError(
f"Invalid inclusive frame interval [{start},{annotation_end}] in "
f"{clip.transcript_path}; clip has {clip.num_frames} frames"
)
dense_start = clip.dense_start_frame + start - 1
dense_end = clip.dense_start_frame + end
uid = f"{session}/{clip.stem}/{row_index:04d}"
clip_rows.append(
Utterance(
uid=uid,
session=session,
clip_stem=clip.stem,
clip_index=clip_index,
segment_id=clip.segment_id,
participant_id=int(row["Participant_ID"]),
target_of_speech=_target_values(row["Target_of_Speech"]),
transcription=str(row["Transcription"]),
source_start_frame=start,
source_end_frame=end,
dense_start_frame=dense_start,
dense_end_frame_exclusive=dense_end,
dense_start_seconds=dense_start / SOURCE_FPS,
dense_end_seconds=dense_end / SOURCE_FPS,
duration_seconds=(end - start + 1) / SOURCE_FPS,
starts_at_clip_boundary=start <= 2,
ends_at_clip_boundary=end >= clip.num_frames - 1,
annotation_end_frame_clamped_by=end_overrun,
is_cross_clip_continuation=False,
)
)
per_clip.append(clip_rows)
# Mark, rather than merge, cross-file continuations. Their timing remains
# useful for floor context, but they must not create a new event onset.
for clip_index, rows in enumerate(per_clip):
previous_rows = per_clip[clip_index - 1] if clip_index > 0 else []
contiguous = (
clip_index > 0 and clips[clip_index].segment_id == clips[clip_index - 1].segment_id
)
for row in rows:
continuation = False
if contiguous and row.starts_at_clip_boundary:
candidates = [
prev
for prev in previous_rows
if prev.participant_id == row.participant_id
and prev.ends_at_clip_boundary
]
continuation = bool(candidates) and (
"[X]" in row.transcription.upper()
or any("[X]" in prev.transcription.upper() for prev in candidates)
)
utterances.append(
Utterance(**{**asdict(row), "is_cross_clip_continuation": continuation})
)
return sorted(utterances, key=lambda u: (u.dense_start_frame, u.dense_end_frame_exclusive, u.uid))
def _overlap_seconds(row: Utterance, start: float, end: float) -> float:
return max(0.0, min(row.dense_end_seconds, end) - max(row.dense_start_seconds, start))
def _coverage_by_participant(
utterances: Sequence[Utterance],
*,
segment_id: int,
start: float,
end: float,
exclude_participant: int | None = None,
) -> dict[int, float]:
intervals: dict[int, list[tuple[float, float]]] = defaultdict(list)
for row in utterances:
if row.segment_id != segment_id or row.participant_id == exclude_participant:
continue
left = max(start, row.dense_start_seconds)
right = min(end, row.dense_end_seconds)
if right > left:
intervals[row.participant_id].append((left, right))
coverage: dict[int, float] = {}
for participant, spans in intervals.items():
spans.sort()
total = 0.0
left, right = spans[0]
for next_left, next_right in spans[1:]:
if next_left <= right:
right = max(right, next_right)
else:
total += right - left
left, right = next_left, next_right
coverage[participant] = total + right - left
return coverage
def _dominant(coverage: dict[int, float]) -> int | None:
if not coverage:
return None
participant, seconds = max(coverage.items(), key=lambda item: (item[1], -item[0]))
return participant if seconds > 1e-9 else None
def _previous_floor(
row: Utterance,
utterances: Sequence[Utterance],
rules: Rules,
) -> tuple[int | None, Utterance | None, float | None, float]:
candidates = [
other
for other in utterances
if other.segment_id == row.segment_id
and other.participant_id != row.participant_id
and other.dense_start_seconds < row.dense_start_seconds
and other.dense_end_seconds >= row.dense_start_seconds - rules.max_switch_gap_seconds
]
if not candidates:
return None, None, None, 0.0
active = [other for other in candidates if other.dense_end_seconds > row.dense_start_seconds]
previous = max(active or candidates, key=lambda other: other.dense_end_seconds)
gap = row.dense_start_seconds - previous.dense_end_seconds
overlap = max(0.0, -gap)
return previous.participant_id, previous, gap, overlap
def classify_utterance(
row: Utterance,
utterances: Sequence[Utterance],
*,
camera_wearer_id: int,
rules: Rules,
) -> Event:
split = "test" if row.session in TEST_SESSIONS else "train"
role = "wearer" if row.participant_id == camera_wearer_id else "other"
lexical = is_lexical_feedback(row.transcription)
nonverbal = is_pure_nonverbal(row.transcription)
previous_pid, previous, gap, onset_overlap = _previous_floor(row, utterances, rules)
pre_coverage = _coverage_by_participant(
utterances,
segment_id=row.segment_id,
start=row.dense_start_seconds - rules.context_seconds,
end=row.dense_start_seconds,
exclude_participant=row.participant_id,
)
post_coverage = _coverage_by_participant(
utterances,
segment_id=row.segment_id,
start=row.dense_end_seconds,
end=row.dense_end_seconds + rules.context_seconds,
exclude_participant=row.participant_id,
)
pre_pid = _dominant(pre_coverage) or previous_pid
post_pid = _dominant(post_coverage)
retained_pid = pre_pid if pre_pid is not None and pre_pid == post_pid else None
old_end = previous.dense_end_seconds if previous is not None else row.dense_start_seconds
speaker_after_old = max(0.0, row.dense_end_seconds - max(row.dense_start_seconds, old_end))
previous_cutoff = has_trailing_cutoff(previous) if previous is not None else False
target_matches = previous_pid is not None and previous_pid in row.target_of_speech
short = row.duration_seconds <= rules.backchannel_max_seconds + 1e-9
overlaps = onset_overlap >= rules.min_overlap_seconds - 1e-9
takeover_candidate = speaker_after_old >= rules.takeover_candidate_seconds - 1e-9
takeover_high = speaker_after_old >= rules.takeover_high_seconds - 1e-9
event_type = "unclassified"
confidence = "none"
trainable = False
reason = "no other-speaker floor relation meeting a proxy rule"
laughter_feedback = nonverbal and "[L]" in row.transcription.upper()
if row.is_cross_clip_continuation:
event_type = "cross_clip_continuation"
reason = "[X] continuation at a contiguous one-minute file boundary"
elif laughter_feedback and (retained_pid is not None or overlaps):
event_type = "nonverbal_feedback"
confidence = "high"
trainable = True
reason = "laughter-only marker during/around another participant's retained floor"
elif nonverbal and (retained_pid is not None or overlaps):
event_type = "nonverbal_candidate"
confidence = "ambiguous"
reason = "pure hesitation/cough/throat-clear/unintelligible marker; feedback function is unknown"
elif short and retained_pid is not None:
if lexical:
event_type = "backchannel_proxy_high"
confidence = "high"
trainable = True
reason = "short lexical feedback; the same other participant dominates before and after"
else:
event_type = "backchannel_candidate"
confidence = "ambiguous"
reason = "short contribution while another participant retains floor, but no conservative lexical cue"
elif previous is not None and (overlaps or (previous_cutoff and gap is not None and gap <= 0.15)):
if previous_cutoff and takeover_candidate:
event_type = "interruption_proxy_high"
confidence = "high"
trainable = True
reason = "previous transcript is cut off and the incoming speaker continues after that floor ends"
elif takeover_high:
event_type = "overlap_takeover"
confidence = "timing"
trainable = True
reason = "overlapping onset followed by sustained takeover, without semantic interruption evidence"
elif takeover_candidate:
event_type = "interruption_candidate"
confidence = "ambiguous"
reason = "overlapping onset followed by a possible short takeover; interruption intent is unknown"
else:
event_type = "overlap_hold"
confidence = "timing"
trainable = True
reason = "overlap without enough evidence that the incoming speaker took the floor"
elif (
previous is not None
and gap is not None
and 0.0 <= gap <= rules.max_switch_gap_seconds
and row.duration_seconds >= rules.takeover_high_seconds
):
event_type = "ordinary_switch"
confidence = "timing"
trainable = True
reason = "other speaker ended recently and the incoming speaker sustained a new turn"
return Event(
uid=row.uid,
session=row.session,
split=split,
event_type=event_type,
confidence=confidence,
trainable_auxiliary=trainable,
participant_id=row.participant_id,
participant_role=role,
previous_floor_participant_id=previous_pid,
retained_floor_participant_id=retained_pid,
target_matches_previous_floor=target_matches,
clip_stem=row.clip_stem,
source_start_frame=row.source_start_frame,
dense_start_seconds=row.dense_start_seconds,
task_step_index=row.dense_start_frame // FRAMES_PER_TASK_STEP,
duration_seconds=row.duration_seconds,
onset_overlap_seconds=onset_overlap,
gap_from_previous_seconds=gap,
speaker_after_previous_end_seconds=speaker_after_old,
previous_transcript_has_cutoff=previous_cutoff,
previous_transcription=None if previous is None else previous.transcription,
lexical_feedback=lexical,
pure_nonverbal=nonverbal,
transcription=row.transcription,
target_of_speech=row.target_of_speech,
reason=reason,
)
def _serialize_event(event: Event) -> dict[str, Any]:
payload = asdict(event)
payload["target_of_speech"] = list(event.target_of_speech)
return payload
def _write_session(
output_dir: Path,
session: str,
events: Sequence[Event],
*,
target_steps: int | None,
write_frame_arrays: bool,
overwrite: bool,
) -> None:
output_dir.mkdir(parents=True, exist_ok=True)
rows = [_serialize_event(event) for event in events if event.event_type != "unclassified"]
jsonl_path = output_dir / f"{session}.events.jsonl"
csv_path = output_dir / f"{session}.events.csv"
for path in (jsonl_path, csv_path):
if path.exists() and not overwrite:
raise FileExistsError(f"Refusing to overwrite {path}; pass --overwrite")
with jsonl_path.open("w", encoding="utf-8") as handle:
for row in rows:
handle.write(json.dumps(row, ensure_ascii=False) + "\n")
fieldnames = list(rows[0]) if rows else [field.name for field in Event.__dataclass_fields__.values()]
with csv_path.open("w", encoding="utf-8", newline="") as handle:
writer = csv.DictWriter(handle, fieldnames=fieldnames)
writer.writeheader()
for row in rows:
row = dict(row)
row["target_of_speech"] = json.dumps(row["target_of_speech"], ensure_ascii=False)
writer.writerow(row)
if write_frame_arrays:
if target_steps is None:
raise ValueError("--write-frame-arrays requires --target-root")
npz_path = output_dir / f"{session}.aux_targets.npz"
if npz_path.exists() and not overwrite:
raise FileExistsError(f"Refusing to overwrite {npz_path}; pass --overwrite")
try:
import numpy as np
except ImportError as exc:
raise RuntimeError("numpy is required to write aligned auxiliary arrays") from exc
onset = np.zeros((target_steps, len(AUXILIARY_EVENT_NAMES)), dtype=np.uint8)
role = np.zeros_like(onset, dtype=np.int8)
collisions = np.zeros_like(onset, dtype=np.uint8)
event_to_column = {name: index for index, name in enumerate(AUXILIARY_EVENT_NAMES)}
for event in events:
column = event_to_column.get(event.event_type)
step = event.task_step_index
if column is None or not event.trainable_auxiliary or not 0 <= step < target_steps:
continue
encoded_role = 1 if event.participant_role == "wearer" else 2
if onset[step, column]:
collisions[step, column] = 1
if role[step, column] != encoded_role:
role[step, column] = -1
else:
onset[step, column] = 1
role[step, column] = encoded_role
np.savez_compressed(
npz_path,
event_onsets=onset,
event_roles=role,
collisions=collisions,
event_names=np.asarray(AUXILIARY_EVENT_NAMES),
task_fps=np.asarray(TASK_FPS),
target_steps=np.asarray(target_steps),
)
def _session_summary(
session: str,
clips: Sequence[Clip],
utterances: Sequence[Utterance],
events: Sequence[Event],
target_steps: int | None,
) -> dict[str, Any]:
counts = Counter(event.event_type for event in events)
trainable = Counter(
event.event_type for event in events if event.trainable_auxiliary
)
dense_frames = sum(clip.num_frames for clip in clips)
expected_steps = dense_frames // FRAMES_PER_TASK_STEP
return {
"session": session,
"split": "test" if session in TEST_SESSIONS else "train",
"clips": len(clips),
"source_discontinuities": max((clip.segment_id for clip in clips), default=0),
"utterances": len(utterances),
"annotation_endpoints_clamped": sum(
row.annotation_end_frame_clamped_by > 0 for row in utterances
),
"annotation_frames_clamped_total": sum(
row.annotation_end_frame_clamped_by for row in utterances
),
"dense_frames": dense_frames,
"expected_5hz_steps": expected_steps,
"target_steps": target_steps,
"target_minus_expected_steps": (
None if target_steps is None else target_steps - expected_steps
),
"event_counts": dict(sorted(counts.items())),
"trainable_auxiliary_counts": dict(sorted(trainable.items())),
}
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--dataset-root", type=Path, required=True)
parser.add_argument(
"--target-root",
type=Path,
help="Optional EgoSpeak target_perframe directory for exact 5 Hz length/aligned .npz output",
)
parser.add_argument("--output-dir", type=Path, help="Omit for a read-only audit")
parser.add_argument(
"--write-frame-arrays",
action="store_true",
help="Write aligned 5 Hz .npz arrays; requires --target-root and numpy",
)
parser.add_argument("--sessions", nargs="+", default=list(ALL_SESSIONS))
parser.add_argument("--camera-wearer-id", type=int, default=CAMERA_WEARER_ID)
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)
parser.add_argument("--examples-per-event", type=int, default=3)
parser.add_argument("--overwrite", action="store_true")
return parser.parse_args()
def main() -> None:
args = parse_args()
if SOURCE_FPS % TASK_FPS:
raise RuntimeError("source/task FPS ratio must be integral")
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,
)
for name, value in asdict(rules).items():
if value < 0:
raise ValueError(f"{name} must be non-negative")
unknown = sorted(set(args.sessions) - set(ALL_SESSIONS))
if unknown:
raise ValueError(f"Unknown EasyCom sessions: {unknown}")
if args.write_frame_arrays and args.output_dir is None:
raise ValueError("--write-frame-arrays requires --output-dir")
if args.write_frame_arrays and args.target_root is None:
raise ValueError("--write-frame-arrays requires --target-root")
all_events: list[Event] = []
summaries: list[dict[str, Any]] = []
for session in args.sessions:
clips = build_clips(args.dataset_root.resolve(), session, rules)
utterances = load_utterances(session, clips)
events = [
classify_utterance(
row,
utterances,
camera_wearer_id=args.camera_wearer_id,
rules=rules,
)
for row in utterances
]
target_path = (
None
if args.target_root is None
else args.target_root.resolve() / f"{session}.npy"
)
if target_path is not None and not target_path.is_file():
raise FileNotFoundError(f"Missing target: {target_path}")
target_steps = _target_length(target_path)
summary = _session_summary(session, clips, utterances, events, target_steps)
if summary["target_minus_expected_steps"] not in (None, 0):
raise ValueError(
f"{session} target/media mismatch: {summary['target_minus_expected_steps']:+d} steps"
)
summaries.append(summary)
all_events.extend(events)
if args.output_dir is not None:
_write_session(
args.output_dir.resolve(),
session,
events,
target_steps=target_steps,
write_frame_arrays=args.write_frame_arrays,
overwrite=args.overwrite,
)
counts = Counter(event.event_type for event in events)
print(
f"{session}: utterances={len(utterances)} "
+ " ".join(f"{name}={count}" for name, count in sorted(counts.items()))
)
aggregate = Counter(event.event_type for event in all_events)
trainable = Counter(
event.event_type for event in all_events if event.trainable_auxiliary
)
report = {
"schema_version": 1,
"dataset": "EasyCom",
"dataset_root": str(args.dataset_root.resolve()),
"target_root": None if args.target_root is None else str(args.target_root.resolve()),
"source_fps": SOURCE_FPS,
"task_fps": TASK_FPS,
"camera_wearer_participant_id": args.camera_wearer_id,
"rules": asdict(rules),
"auxiliary_event_names": list(AUXILIARY_EVENT_NAMES),
"ambiguous_event_names": list(AMBIGUOUS_EVENT_NAMES),
"semantic_warning": (
"Backchannel and interruption are transcript/timing-derived weak proxies, "
"not released EasyCom ground-truth semantic annotations."
),
"recommended_training_policy": (
"Use only trainable_auxiliary events for auxiliary loss; mask candidate and "
"unclassified rows. Keep EgoSpeak's original three-label evaluator unchanged."
),
"event_counts": dict(sorted(aggregate.items())),
"trainable_auxiliary_counts": dict(sorted(trainable.items())),
"sessions": summaries,
"examples": {},
}
for event_type in sorted(aggregate):
report["examples"][event_type] = [
_serialize_event(event)
for event in all_events
if event.event_type == event_type
][: args.examples_per_event]
print("\nAggregate event counts")
for name, count in sorted(aggregate.items()):
print(f" {name:<28} {count:>6}")
print("Trainable auxiliary counts")
for name, count in sorted(trainable.items()):
print(f" {name:<28} {count:>6}")
if args.examples_per_event:
print("Examples")
for event_type in sorted(aggregate):
examples = [
event for event in all_events if event.event_type == event_type
][: args.examples_per_event]
for event in examples:
text = " ".join(event.transcription.split())
if len(text) > 100:
text = text[:97] + "..."
print(
f" {event_type:<28} {event.session} "
f"t={event.dense_start_seconds:.2f}s p={event.participant_id}: {text}"
)
print(
"\nWARNING: backchannel/interruption outputs are weak proxies; "
"ambiguous candidates are intentionally masked."
)
if args.output_dir is not None:
output_dir = args.output_dir.resolve()
output_dir.mkdir(parents=True, exist_ok=True)
report_path = output_dir / "summary.json"
if report_path.exists() and not args.overwrite:
raise FileExistsError(f"Refusing to overwrite {report_path}; pass --overwrite")
report_path.write_text(
json.dumps(report, indent=2, ensure_ascii=False) + "\n",
encoding="utf-8",
)
print(f"Saved reports to {output_dir}")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,101 @@
"""Tests for DeepSeek interaction-event adjudication without network calls."""
from __future__ import annotations
import unittest
from adjudicate_easycom_interaction_events import (
OUTPUT_SCHEMA_VERSION,
PROMPT_VERSION,
_validate_decisions,
_validate_resume_rows,
build_user_prompt,
stratified_limit,
)
class AdjudicationTests(unittest.TestCase):
def test_prompt_contains_json_and_candidate_context(self) -> None:
prompt = build_user_prompt(
[{"uid": "u1", "event_type": "backchannel_candidate", "transcription": "Yeah."}],
0,
)
self.assertIn("JSON", prompt)
self.assertIn("u1", prompt)
self.assertIn("Yeah.", prompt)
def test_response_requires_exact_uid_set(self) -> None:
with self.assertRaisesRegex(ValueError, "mismatch"):
_validate_decisions({"items": []}, ["u1"])
def test_valid_response_is_returned_in_input_order(self) -> None:
payload = {
"items": [
{"uid": "u2", "label": "uncertain", "confidence": "low", "rationale": "Not enough evidence."},
{"uid": "u1", "label": "backchannel", "confidence": "high", "rationale": "Floor is retained."},
]
}
actual = _validate_decisions(payload, ["u1", "u2"])
self.assertEqual([row["uid"] for row in actual], ["u1", "u2"])
def test_invalid_label_is_rejected(self) -> None:
payload = {
"items": [
{"uid": "u1", "label": "guess", "confidence": "high", "rationale": "No."}
]
}
with self.assertRaisesRegex(ValueError, "Invalid label"):
_validate_decisions(payload, ["u1"])
def test_resume_rejects_mixed_model(self) -> None:
rows = [
{
"uid": "u1",
"schema_version": OUTPUT_SCHEMA_VERSION,
"model": "different-model",
"prompt_version": PROMPT_VERSION,
"passes": 2,
}
]
with self.assertRaisesRegex(ValueError, "different adjudication"):
_validate_resume_rows(rows, model="deepseek-v4-flash", passes=2)
def test_resume_accepts_matching_configuration(self) -> None:
rows = [
{
"uid": "u1",
"schema_version": OUTPUT_SCHEMA_VERSION,
"model": "deepseek-v4-flash",
"prompt_version": PROMPT_VERSION,
"passes": 2,
}
]
_validate_resume_rows(rows, model="deepseek-v4-flash", passes=2)
def test_pilot_limit_is_stratified_and_deterministic(self) -> None:
rows = [
{
"uid": f"{event_type}-{session}-{index}",
"event_type": event_type,
"session": session,
}
for event_type in ("backchannel_candidate", "interruption_candidate")
for session in ("Session_4", "Session_5")
for index in range(3)
]
first = stratified_limit(rows, 4, seed=2027)
second = stratified_limit(rows, 4, seed=2027)
self.assertEqual(first, second)
self.assertEqual(
{(row["event_type"], row["session"]) for row in first},
{
("backchannel_candidate", "Session_4"),
("backchannel_candidate", "Session_5"),
("interruption_candidate", "Session_4"),
("interruption_candidate", "Session_5"),
},
)
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,108 @@
"""Focused tests for transcript-derived EasyCom interaction proxies."""
from __future__ import annotations
import unittest
from derive_easycom_interaction_events import Rules, Utterance, classify_utterance
def utterance(
uid: str,
participant: int,
start: float,
end: float,
text: str,
*,
target: tuple[int | str, ...] = ("Group",),
) -> Utterance:
return Utterance(
uid=uid,
session="Session_7",
clip_stem="00-00-000",
clip_index=0,
segment_id=0,
participant_id=participant,
target_of_speech=target,
transcription=text,
source_start_frame=round(start * 20) + 1,
source_end_frame=round(end * 20),
dense_start_frame=round(start * 20),
dense_end_frame_exclusive=round(end * 20),
dense_start_seconds=start,
dense_end_seconds=end,
duration_seconds=end - start,
starts_at_clip_boundary=False,
ends_at_clip_boundary=False,
annotation_end_frame_clamped_by=0,
is_cross_clip_continuation=False,
)
class InteractionProxyTests(unittest.TestCase):
def setUp(self) -> None:
self.rules = Rules()
def classify(self, target: Utterance, rows: list[Utterance]):
return classify_utterance(
target,
rows,
camera_wearer_id=2,
rules=self.rules,
)
def test_short_feedback_inside_retained_floor_is_backchannel_proxy(self) -> None:
floor = utterance("floor", 4, 0.0, 4.0, "Long explanation")
feedback = utterance("feedback", 2, 1.0, 1.4, "Yeah.", target=(4,))
event = self.classify(feedback, [floor, feedback])
self.assertEqual(event.event_type, "backchannel_proxy_high")
self.assertTrue(event.trainable_auxiliary)
self.assertEqual(event.retained_floor_participant_id, 4)
def test_short_answer_without_floor_return_is_not_backchannel(self) -> None:
question = utterance("question", 4, 0.0, 1.0, "Ready?")
answer = utterance("answer", 2, 1.1, 2.3, "Yeah, let us start.", target=(4,))
event = self.classify(answer, [question, answer])
self.assertEqual(event.event_type, "ordinary_switch")
def test_overlap_takeover_after_cutoff_is_interruption_proxy(self) -> None:
old = utterance("old", 4, 0.0, 2.0, "I was going to-")
incoming = utterance("incoming", 2, 1.7, 3.2, "Wait, that is wrong.", target=(4,))
event = self.classify(incoming, [old, incoming])
self.assertEqual(event.event_type, "interruption_proxy_high")
self.assertTrue(event.trainable_auxiliary)
def test_overlap_takeover_without_cutoff_is_not_called_interruption(self) -> None:
old = utterance("old", 4, 0.0, 2.0, "I finished my point.")
incoming = utterance("incoming", 2, 1.7, 3.2, "Here is my response.", target=(4,))
event = self.classify(incoming, [old, incoming])
self.assertEqual(event.event_type, "overlap_takeover")
self.assertTrue(event.trainable_auxiliary)
def test_short_nonlexical_floor_return_is_masked_candidate(self) -> None:
before = utterance("before", 4, 0.0, 1.2, "First part")
brief = utterance("brief", 2, 1.25, 1.8, "Possibly.", target=(4,))
after = utterance("after", 4, 1.9, 3.0, "Second part")
event = self.classify(brief, [before, brief, after])
self.assertEqual(event.event_type, "backchannel_candidate")
self.assertFalse(event.trainable_auxiliary)
def test_hesitation_marker_is_not_nonverbal_feedback(self) -> None:
floor = utterance("floor", 4, 0.0, 4.0, "Long explanation")
hesitation = utterance("hesitation", 2, 1.0, 1.2, "[H]", target=(4,))
event = self.classify(hesitation, [floor, hesitation])
self.assertEqual(event.event_type, "nonverbal_candidate")
self.assertFalse(event.trainable_auxiliary)
def test_cross_clip_continuation_never_creates_switch(self) -> None:
row = utterance("continued", 2, 60.0, 61.0, "[X] continued")
row = Utterance(
**{**row.__dict__, "is_cross_clip_continuation": True}
)
event = self.classify(row, [row])
self.assertEqual(event.event_type, "cross_clip_continuation")
self.assertFalse(event.trainable_auxiliary)
if __name__ == "__main__":
unittest.main()