Delegate wearer boundary semantics to review

This commit is contained in:
Xin Wang
2026-09-15 02:12:18 +08:00
parent 71dfd8808a
commit 3a127a3f9f
12 changed files with 596 additions and 404 deletions

View File

@@ -304,7 +304,7 @@ Do not reorganize them physically without compatibility wrappers.
| `derive_easycom_interaction_events.py` | Deterministic transcript/timing proxies | | `derive_easycom_interaction_events.py` | Deterministic transcript/timing proxies |
| `adjudicate_easycom_interaction_events.py` | Optional ambiguous-event adjudication | | `adjudicate_easycom_interaction_events.py` | Optional ambiguous-event adjudication |
| `audit_easycom_floor_events.py` | Count EOT/takeover/backchannel candidates | | `audit_easycom_floor_events.py` | Count EOT/takeover/backchannel candidates |
| `prepare_easycom_eot_int_review.py` | Group short non-competed wearer resumptions into floor episodes; retain every resulting wearer onset as an entry candidate with optional preceding-release metadata, require `wearer_floor_start < other_onset < wearer_offset` plus boundary crossing for INT, preserve ineligible raw boundaries as controls, and scan independent hard negatives; semantic labels remain AI/human review outputs | | `prepare_easycom_eot_int_review.py` | Retain every raw wearer onset and offset as an unresolved semantic candidate; attach nearby participant boundaries, activity relations, transcripts, and target-of-speech facts without entry/takeover gating; also scan independent hard-negative regions; semantic labels remain AI/human review outputs |
| `adjudicate_easycom_eot_int_candidates.py` | Add optional DeepSeek transcript/timing suggestions; never human ground truth | | `adjudicate_easycom_eot_int_candidates.py` | Add optional DeepSeek transcript/timing suggestions; never human ground truth |
| `review_floor_events.py` | Serve the local video/transcript workspace and persist human event reviews | | `review_floor_events.py` | Serve the local video/transcript workspace and persist human event reviews |
| `prepare_easycom_oracle_features.py` | Build explicit past-only oracle person-activity sidecars and analysis-only addressee controls | | `prepare_easycom_oracle_features.py` | Build explicit past-only oracle person-activity sidecars and analysis-only addressee controls |

View File

@@ -23,10 +23,11 @@ from typing import Any, Sequence, TextIO
import urllib.error import urllib.error
import urllib.request import urllib.request
from easycom_event_taxonomy import EXCLUDE_REASONS, exclude_reason_group
from prepare_easycom_eot_int_review import requires_semantic_adjudication from prepare_easycom_eot_int_review import requires_semantic_adjudication
PROMPT_VERSION = "easycom-wearer-entry-int-prelabel-v12" PROMPT_VERSION = "easycom-wearer-boundary-semantic-prelabel-v14"
DEFAULT_MODEL = "deepseek-v4-flash" DEFAULT_MODEL = "deepseek-v4-flash"
DEFAULT_BASE_URL = "https://api.deepseek.com" DEFAULT_BASE_URL = "https://api.deepseek.com"
VERDICTS = {"POSITIVE", "HARD_NEGATIVE", "EXCLUDE"} VERDICTS = {"POSITIVE", "HARD_NEGATIVE", "EXCLUDE"}
@@ -40,8 +41,6 @@ HARD_NEGATIVE_TYPES = {
"INT_WEARER_HOLDS_FLOOR", "INT_WEARER_HOLDS_FLOOR",
"OTHER_HARD_NEGATIVE", "OTHER_HARD_NEGATIVE",
} }
def _progress_line( def _progress_line(
completed: int, total: int, *, label: str, elapsed_seconds: float, width: int = 26 completed: int, total: int, *, label: str, elapsed_seconds: float, width: int = 26
) -> str: ) -> str:
@@ -91,10 +90,10 @@ SYSTEM_PROMPT = """You conservatively pre-label candidate regions for a scientif
Evidence rules: Evidence rules:
1. Reconstruct the event chronologically before assigning semantics. Use exact start/end timestamps to identify (a) the proposed release or competing onset, (b) the wearer behavior anchor, (c) which same participant continues across the anchor, and (d) who produces the next substantive turn. Then use transcript content and Target_of_Speech, when supplied, to decide conversational function. Timing has priority over speculative transcript interpretation, but duration alone never determines floor function. 1. Reconstruct the event chronologically before assigning semantics. Use exact start/end timestamps to identify (a) the proposed release or competing onset, (b) the wearer behavior anchor, (c) which same participant continues across the anchor, and (d) who produces the next substantive turn. Then use transcript content and Target_of_Speech, when supplied, to decide conversational function. Timing has priority over speculative transcript interpretation, but duration alone never determines floor function.
2. Never alter or propose an event timestamp. Do not infer prosody, gaze, intention, or acoustic cues that were not supplied. 2. Never alter or propose an event timestamp. Do not infer prosody, gaze, intention, or acoustic cues that were not supplied.
3. A raw wearer onset is not automatically a floor entry; a wearer offset is not automatically a yield; an other onset is not automatically a takeover. A pause is not automatically a release, overlap is not automatically interruption, and words such as "yeah" are not automatically backchannels. Determine what the utterance does in context, not only how long it lasts. 3. Every supplied WEARER_ONSET and WEARER_OFFSET is a raw observed wearer activity boundary. Candidate generation has made no floor-entry, within-turn-resumption, interruption, or takeover decision. A raw wearer onset is not automatically a floor entry; a wearer offset is not automatically a yield; an other onset is not automatically a takeover. A pause is not automatically a release, overlap is not automatically interruption, and words such as "yeah" are not automatically backchannels. Determine what the utterance does in context, not only how long it lasts.
4. EXCLUDE is the sole non-scoring outcome. Use it both for insufficient/unresolvable evidence and for clear out-of-scope non-events that should not enter evaluation, including a normal wearer completion that merely coincides with laughter or feedback. Never force such a region into HARD_NEGATIVE for coverage. 4. EXCLUDE is the sole non-scoring outcome. Use it both for insufficient/unresolvable evidence and for clear out-of-scope non-events that should not enter evaluation, including a normal wearer completion that merely coincides with laughter or feedback. Never force such a region into HARD_NEGATIVE for coverage.
5. Every supplied WEARER_ONSET is an observed floor-level onset after deterministic within-turn pause merging. Decide whether the wearer genuinely enters or re-enters the conversational floor. A POSITIVE WEARER_ENTRY does not require a preceding non-wearer release: it may occur after a release, from silence, or while another speaker is active. temporal_evidence.opportunity_options and entry_context_timing are context metadata only. If one supplied release clearly prompted the entry, selected_opportunity_uid may contain its uid; otherwise return null. Never invent a trigger or timestamp. A direct, semantically complete answer can be a genuine entry even when short. Do not call it a backchannel merely because it lasts less than one second or because another participant speaks later. For example, "Nope." beginning 0.15 s after an addressed question ends is normally WEARER_ENTRY, not ENTRY_WEARER_BACKCHANNEL. Later conversation does not retroactively cancel a completed entry. 5. For every WEARER_ONSET, decide whether the wearer genuinely enters or re-enters the conversational floor. First inspect the previous wearer offset and silence gap, gap_other_intervals, nearby other activity, transcript, and Target_of_Speech. A short within-turn pause followed by continuation of the same wearer contribution is not a new entry; use EXCLUDE unless the region is a valid named false-positive opportunity. A POSITIVE WEARER_ENTRY does not require a preceding non-wearer release: it may occur after a release, from silence, or while another speaker is active. temporal_evidence.opportunity_options contains nearby preceding other offsets with factual relation flags, not pre-approved causes. If one supplied release clearly prompted the entry, selected_opportunity_uid may contain its uid; otherwise return null. Never invent a trigger or timestamp. A direct, semantically complete answer can be a genuine entry even when short. Do not call it a backchannel merely because it lasts less than one second or because another participant speaks later. For example, "Nope." beginning 0.15 s after an addressed question ends is normally WEARER_ENTRY, not ENTRY_WEARER_BACKCHANNEL. Later conversation does not retroactively cancel a completed entry.
6. Every supplied WEARER_OFFSET has already passed both deterministic INT prerequisites: a new non-wearer onset occurs strictly inside the current wearer floor episode (t_w < t_o < t_y), and that same non-wearer interval crosses the wearer yield boundary within the recorded annotation tolerance. This is only a retrieval gate. Verify that the same incoming non-wearer actually takes and sustains the next floor after the wearer offset. A different participant's later substantive turn is not evidence that the incoming speaker took over. Do not label INT_WEARER_HOLDS_FLOOR merely because the wearer speaks again later: if the competing other establishes an intervening substantive floor and the wearer has an intervening silent interval, the later wearer onset is a new WEARER_ENTRY and does not invalidate the earlier INT_YIELD. If the wearer reaches a semantically complete natural ending while the overlapping contribution is only [L] or feedback, and that contributor does not take the next substantive floor, return EXCLUDE: out-of-scope normal completion, not INT_YIELD and not a scored hard negative. Pre-existing other activity, post-completion other onset, within-turn wearer pauses, and completion-to-silence are controls outside this queue. 6. For every WEARER_OFFSET, answer the central question: did the wearer stop because a non-wearer genuinely took over the conversational floor? Reconstruct the wearer activity-run start and offset, every nearby other onset and offset, which others are active immediately before and after the offset, whether each other is pre-existing or starts during wearer speech, whether it crosses the offset, overlap duration, who speaks next, transcript content, and Target_of_Speech. All temporal booleans are descriptive evidence, not prerequisite labels. A valid INT_YIELD may involve a newly entering or pre-existing competitor if the chronology and semantics establish genuine takeover and wearer relinquishment. Conversely, crossing the offset or satisfying t_w < t_o < t_y never proves takeover by itself. Verify that the relevant non-wearer actually takes and sustains the next floor. A different participant's later substantive turn is not evidence that the proposed competitor took over. Do not label INT_WEARER_HOLDS_FLOOR merely because the wearer speaks again later: if a competitor establishes an intervening substantive floor and the wearer has an intervening silent interval, the later wearer onset is a new WEARER_ENTRY and does not invalidate the earlier INT_YIELD. Natural completion, completion-to-silence, a within-turn wearer pause, or a post-completion other onset that could not have caused the stop is normally EXCLUDE unless the region is a valid named hard-negative opportunity. If the wearer reaches a semantically complete natural ending while overlapping activity is only [L] or feedback and that contributor does not take the next substantive floor, return EXCLUDE.
7. Transcript event markers are evidence, not lexical turns. In EasyCom, a transcription consisting of [L] denotes laughter-only vocalization. Do not treat [L] as a sustained floor entry or interruption takeover merely because its activity interval creates an onset, offset, or overlap candidate. Specifically: 7. Transcript event markers are evidence, not lexical turns. In EasyCom, a transcription consisting of [L] denotes laughter-only vocalization. Do not treat [L] as a sustained floor entry or interruption takeover merely because its activity interval creates an onset, offset, or overlap candidate. Specifically:
- wearer [L] during a non-wearer's retained turn is normally HARD_NEGATIVE / ENTRY_WEARER_BACKCHANNEL, not WEARER_ENTRY; - wearer [L] during a non-wearer's retained turn is normally HARD_NEGATIVE / ENTRY_WEARER_BACKCHANNEL, not WEARER_ENTRY;
- non-wearer [L] overlapping a wearer's retained turn is normally HARD_NEGATIVE / INT_OTHER_BACKCHANNEL, not INT_YIELD; - non-wearer [L] overlapping a wearer's retained turn is normally HARD_NEGATIVE / INT_OTHER_BACKCHANNEL, not INT_YIELD;
@@ -106,6 +105,19 @@ Top-level verdicts:
- HARD_NEGATIVE: a deliberately retained, valid false-positive opportunity matching one of the behavioral counterexamples below; it enters evaluation as a negative. - HARD_NEGATIVE: a deliberately retained, valid false-positive opportunity matching one of the behavioral counterexamples below; it enters evaluation as a negative.
- EXCLUDE: a non-scoring region. This includes clear out-of-scope/non-target opportunities such as natural wearer completion coincident with feedback, as well as insufficient evidence, annotation corruption, a recording boundary, missing context, a non-speech artifact, timestamp corruption, or another unresolvable defect. - EXCLUDE: a non-scoring region. This includes clear out-of-scope/non-target opportunities such as natural wearer completion coincident with feedback, as well as insufficient evidence, annotation corruption, a recording boundary, missing context, a non-speech artifact, timestamp corruption, or another unresolvable defect.
EXCLUDE requires exactly one exclude_reason from this closed taxonomy:
- OUT_OF_SCOPE_NORMAL_COMPLETION: the wearer naturally completes; no interruption-yield target occurs.
- OUT_OF_SCOPE_WITHIN_TURN_CONTINUATION: a raw boundary is only a pause/resumption within the same wearer contribution.
- OUT_OF_SCOPE_POST_COMPLETION_ACTIVITY: another contribution begins only after wearer completion and cannot have caused the boundary.
- OUT_OF_SCOPE_NON_EVENT: another clear, interpretable non-target boundary not covered above.
- INSUFFICIENT_EVIDENCE: the supplied valid evidence cannot resolve conversational function.
- DATA_ANNOTATION_ERROR: participant, speech, transcript, or target annotation is evidently erroneous or contradictory.
- DATA_RECORDING_BOUNDARY: the recording/clip boundary prevents reliable interpretation.
- DATA_MISSING_CONTEXT: required context is absent from the supplied annotation window or source.
- DATA_NON_SPEECH_ARTIFACT: the boundary is caused by an artifact rather than an annotated conversational vocalization.
- DATA_TIMESTAMP_CORRUPTION: timestamps are corrupted or irreconcilably misaligned.
The OUT_OF_SCOPE_* reasons describe valid data outside the evaluation target. DATA_* reasons describe bad or unusable data. Never substitute one family for the other.
POSITIVE event_type must be exactly WEARER_ENTRY or INT_YIELD. WEARER_ENTRY means the wearer genuinely enters or re-enters the floor with a contextually complete response or substantive contribution; no prior release is required and the contribution need not be long. INT_YIELD means a non-wearer genuinely takes over during wearer speech and the wearer relinquishes the floor. A later WEARER_ENTRY after an intervening other floor is a new event and must not retroactively invalidate the preceding INT_YIELD. POSITIVE event_type must be exactly WEARER_ENTRY or INT_YIELD. WEARER_ENTRY means the wearer genuinely enters or re-enters the floor with a contextually complete response or substantive contribution; no prior release is required and the contribution need not be long. INT_YIELD means a non-wearer genuinely takes over during wearer speech and the wearer relinquishes the floor. A later WEARER_ENTRY after an intervening other floor is a new event and must not retroactively invalidate the preceding INT_YIELD.
HARD_NEGATIVE subtypes are behavioral counterexamples, not data-quality labels: HARD_NEGATIVE subtypes are behavioral counterexamples, not data-quality labels:
@@ -124,12 +136,12 @@ Subtype precedence when descriptions overlap:
4. Use OTHER_HARD_NEGATIVE only after the named subtypes have been ruled out. Never use it merely because confidence is low. 4. Use OTHER_HARD_NEGATIVE only after the named subtypes have been ruled out. Never use it merely because confidence is low.
Return exactly one JSON object with this shape and no markdown: Return exactly one JSON object with this shape and no markdown:
{"items":[{"uid":"...","verdict":"EXCLUDE","event_type":null,"subtype":null,"selected_opportunity_uid":null,"confidence":0.25,"reason":"brief evidence-based sentence"}]} {"items":[{"uid":"...","verdict":"EXCLUDE","event_type":null,"subtype":null,"exclude_reason":"INSUFFICIENT_EVIDENCE","selected_opportunity_uid":null,"confidence":0.25,"reason":"brief evidence-based sentence"}]}
Field combinations are strict: Field combinations are strict:
- POSITIVE / WEARER_ENTRY: subtype=null; selected_opportunity_uid is one supplied opportunity uid or null. - POSITIVE / WEARER_ENTRY: subtype=null; exclude_reason=null; selected_opportunity_uid is one supplied opportunity uid or null.
- POSITIVE / INT_YIELD: subtype=null; selected_opportunity_uid=null. - POSITIVE / INT_YIELD: subtype=null; exclude_reason=null; selected_opportunity_uid=null.
- HARD_NEGATIVE: event_type=null; selected_opportunity_uid=null; subtype is one valid subtype from the candidate's ENTRY or INT family. - HARD_NEGATIVE: event_type=null; exclude_reason=null; selected_opportunity_uid=null; subtype is one valid subtype from the candidate's ENTRY or INT family.
- EXCLUDE: event_type=null; subtype=null; selected_opportunity_uid=null. - EXCLUDE: event_type=null; subtype=null; selected_opportunity_uid=null; exclude_reason is exactly one taxonomy value above.
Return every supplied uid exactly once. Copy uid byte-for-byte and never add a backslash before an underscore. confidence must be a number from 0 to 1.""" Return every supplied uid exactly once. Copy uid byte-for-byte and never add a backslash before an underscore. confidence must be a number from 0 to 1."""
@@ -245,6 +257,7 @@ def validate_decisions(
verdict = item.get("verdict") verdict = item.get("verdict")
event_type = item.get("event_type") event_type = item.get("event_type")
subtype = item.get("subtype") subtype = item.get("subtype")
exclude_reason = item.get("exclude_reason")
selected_opportunity_uid = item.get("selected_opportunity_uid") selected_opportunity_uid = item.get("selected_opportunity_uid")
confidence = item.get("confidence") confidence = item.get("confidence")
reason = item.get("reason") reason = item.get("reason")
@@ -260,6 +273,10 @@ def validate_decisions(
raise ValueError(f"HARD_NEGATIVE requires a valid subtype for {uid}") raise ValueError(f"HARD_NEGATIVE requires a valid subtype for {uid}")
if verdict != "HARD_NEGATIVE" and subtype is not None: if verdict != "HARD_NEGATIVE" and subtype is not None:
raise ValueError(f"Only HARD_NEGATIVE may have subtype for {uid}") raise ValueError(f"Only HARD_NEGATIVE may have subtype for {uid}")
if verdict == "EXCLUDE" and exclude_reason not in EXCLUDE_REASONS:
raise ValueError(f"EXCLUDE requires a valid exclude_reason for {uid}")
if verdict != "EXCLUDE" and exclude_reason is not None:
raise ValueError(f"Only EXCLUDE may have exclude_reason for {uid}")
family = None if expected_families is None else expected_families.get(str(uid)) family = None if expected_families is None else expected_families.get(str(uid))
if verdict == "POSITIVE" and family in {"ENTRY", "INT"}: if verdict == "POSITIVE" and family in {"ENTRY", "INT"}:
required_event_type = "WEARER_ENTRY" if family == "ENTRY" else "INT_YIELD" required_event_type = "WEARER_ENTRY" if family == "ENTRY" else "INT_YIELD"
@@ -287,6 +304,8 @@ def validate_decisions(
"verdict": str(verdict), "verdict": str(verdict),
"event_type": event_type, "event_type": event_type,
"subtype": subtype, "subtype": subtype,
"exclude_reason": exclude_reason,
"exclude_reason_group": exclude_reason_group(exclude_reason),
"selected_opportunity_uid": selected_opportunity_uid, "selected_opportunity_uid": selected_opportunity_uid,
"confidence": float(confidence), "confidence": float(confidence),
"reason": reason.strip(), "reason": reason.strip(),
@@ -437,12 +456,12 @@ def add_suggestions(
for source in rows: for source in rows:
decisions = by_uid[source["uid"]] decisions = by_uid[source["uid"]]
labels = { labels = {
(d["verdict"], d["event_type"], d["subtype"]) (d["verdict"], d["event_type"], d["subtype"], d["exclude_reason"])
for d in decisions for d in decisions
} }
unanimous = len(labels) == 1 unanimous = len(labels) == 1
if unanimous: if unanimous:
verdict, event_type, subtype = next(iter(labels)) verdict, event_type, subtype, exclude_reason = next(iter(labels))
selected_options = {d["selected_opportunity_uid"] for d in decisions} selected_options = {d["selected_opportunity_uid"] for d in decisions}
selected_opportunity_uid = ( selected_opportunity_uid = (
next(iter(selected_options)) if len(selected_options) == 1 else None next(iter(selected_options)) if len(selected_options) == 1 else None
@@ -451,7 +470,9 @@ def add_suggestions(
reason = " | ".join(item["reason"] for item in decisions) reason = " | ".join(item["reason"] for item in decisions)
needs_human_review = any(item["needs_human_review"] for item in decisions) needs_human_review = any(item["needs_human_review"] for item in decisions)
else: else:
verdict, event_type, subtype, selected_opportunity_uid = "EXCLUDE", None, None, None verdict, event_type, subtype, exclude_reason, selected_opportunity_uid = (
"EXCLUDE", None, None, "INSUFFICIENT_EVIDENCE", None
)
confidence = min(float(item["confidence"]) for item in decisions) confidence = min(float(item["confidence"]) for item in decisions)
reason = "The independent DeepSeek passes disagreed." reason = "The independent DeepSeek passes disagreed."
needs_human_review = True needs_human_review = True
@@ -474,6 +495,8 @@ def add_suggestions(
"verdict": verdict, "verdict": verdict,
"event_type": event_type, "event_type": event_type,
"subtype": subtype, "subtype": subtype,
"exclude_reason": exclude_reason,
"exclude_reason_group": exclude_reason_group(exclude_reason),
"selected_opportunity_uid": selected_opportunity_uid, "selected_opportunity_uid": selected_opportunity_uid,
"selected_opportunity_seconds": selected_opportunity_seconds, "selected_opportunity_seconds": selected_opportunity_seconds,
"valid_prediction_interval_seconds": ( "valid_prediction_interval_seconds": (
@@ -490,6 +513,8 @@ def add_suggestions(
ai_verdict=verdict, ai_verdict=verdict,
ai_event_type=event_type, ai_event_type=event_type,
ai_subtype=subtype, ai_subtype=subtype,
ai_exclude_reason=exclude_reason,
ai_exclude_reason_group=exclude_reason_group(exclude_reason),
ai_selected_opportunity_uid=selected_opportunity_uid, ai_selected_opportunity_uid=selected_opportunity_uid,
ai_selected_opportunity_seconds=selected_opportunity_seconds, ai_selected_opportunity_seconds=selected_opportunity_seconds,
ai_confidence=confidence, ai_confidence=confidence,

View File

@@ -6,7 +6,7 @@ ground truth.
```text ```text
EasyCom participant speech intervals EasyCom participant speech intervals
-> floor-episode grouping + rule candidates + structured timing evidence -> all wearer boundaries + independent rule regions + structured factual evidence
-> DeepSeek semantic pre-label (optional but recommended) -> DeepSeek semantic pre-label (optional but recommended)
-> human Accept / Relabel / Exclude -> human Accept / Relabel / Exclude
``` ```
@@ -25,47 +25,37 @@ python3 prepare_easycom_eot_int_review.py \
The generator emits two independent files: The generator emits two independent files:
- `easycom_eot_int_candidates.jsonl` (legacy-compatible filename) contains every - `easycom_eot_int_candidates.jsonl` (legacy-compatible filename) contains every
floor-level wearer `0->1` behavior anchor (`WEARER_ONSET`, raw wearer `0->1` boundary (`WEARER_ONSET`, `WEARER_ENTRY_CANDIDATE`), every
`WEARER_ENTRY_CANDIDATE`), only episode-ending wearer raw wearer `1->0` boundary (`WEARER_OFFSET`, `YIELD_CANDIDATE`), and hard-negative
`1->0` boundaries where a new non-wearer onset satisfies
`wearer_floor_start < other_onset < wearer_offset` and the same activity
crosses the offset (`WEARER_OFFSET`, `YIELD_CANDIDATE`), and hard-negative
regions that need no wearer transition: regions that need no wearer transition:
`SAME_SPEAKER_PAUSE`, `OTHER_TO_OTHER`, `OTHER_BACKCHANNEL`, and `SAME_SPEAKER_PAUSE`, `OTHER_TO_OTHER`, `OTHER_BACKCHANNEL`, and
`NON_TAKEOVER_OVERLAP`; `NON_TAKEOVER_OVERLAP`;
- `easycom_eot_int_controls.jsonl` contains short within-turn raw onsets - `easycom_eot_int_controls.jsonl` is retained for file-format compatibility
(`WEARER_RESUME`) and wearer offsets that fail and legacy inputs. The current boundary protocol does not deterministically
the strict INT ordering/crossing prerequisite (`WEARER_OFFSET_CONTROL`). They demote wearer onsets or offsets into this file.
are retained for audits, but are not sent to DeepSeek or placed in the
primary human-review queue.
A raw wearer onset is merged into its preceding floor episode only when its No short-gap merge or takeover heuristic controls wearer-boundary admission.
preceding silence is at most 0.8 seconds and no plausible sustained non-wearer For each raw onset, `temporal_evidence` stores the previous wearer offset,
takeover occurs in that gap. Short feedback-like lexical or nonverbal activity silence duration, every other-speaker interval intersecting that gap, interval
is recorded but does not by itself split the episode. A non-feedback other coverage, whether it continues across the onset, transcript, and
contribution that occupies at least `continuation_min_seconds` of the gap or `Target_of_Speech`. DeepSeek and the human reviewer decide whether the onset is
continues across the resumed onset conservatively preserves a new wearer-entry a genuine entry, within-turn resumption, backchannel, or excluded non-event.
candidate. Every decision is stored in `temporal_evidence`, including the raw
gap, feedback IDs, competing IDs, threshold, and `floor_episode_merged` flag.
Entry construction retains every floor-level wearer onset, then searches backward Entry construction searches backward for nearby non-wearer releases as optional
for preceding non-wearer releases as optional context. A release is an admissible option only when the context. Every option carries factual flags for intervening wearer activity,
wearer stays silent and no new non-wearer onset or already-active other floor intervening other onsets, and activity at the wearer onset; the generator does
invalidates the interval before the wearer onset. Silence length alone does not not declare one a valid conversational cause. Each wearer onset produces exactly
invalidate an option. Each wearer onset produces exactly one primary row. A one primary row. When present, `opportunity_options` contains the only release
missing release does not demote an entry to a control: an entry may occur from anchors that DeepSeek or a human may optionally select; neither may invent a
silence or while another speaker is active. `entry_context_timing` records timestamp.
`AFTER_OTHER_RELEASE`, `OTHER_ACTIVE`, or `FROM_SILENCE_OR_UNRESOLVED` as
metadata. When present, `opportunity_options` contains the only release anchors
that DeepSeek or a human may optionally select; neither may invent a timestamp.
Floor-level wearer onsets and strictly qualified offsets carry the rule-stage value All wearer onsets and offsets carry the rule-stage value `candidate`; timing
`candidate`; timing alone is not a semantic positive label. Deterministic rows alone is not a semantic positive label. For each offset, the JSON records the
outside the primary queue carry `control`, never a semantic verdict. The INT prerequisite is wearer activity-run start, nearby other intervals, pre-existing and new-other
`wearer_floor_start < other.start < wearer_offset < other.end`, with a flags, activity immediately before/after the boundary, strict and configurable
configurable one-frame (50 ms) tolerance only at the offset boundary. 50-ms-tolerant crossing, overlap duration, and subsequent speech onsets.
Pre-existing other activity and raw offsets inside a merged wearer episode stay `wearer_start < other.start < wearer_offset < other.end` is evidence rather than
in the controls file. Hard-negative scans are rule hints and still require AI an INT prerequisite. Hard-negative scans are rule hints and still require AI
and human review. The JSONL retains candidate source, original event time, and human review. The JSONL retains candidate source, original event time,
participant-level activity, raw durations/gaps/overlap evidence, transcript, participant-level activity, raw durations/gaps/overlap evidence, transcript,
and `Target_of_Speech` when present. `Target_of_Speech` is annotation assistance and `Target_of_Speech` when present. `Target_of_Speech` is annotation assistance
@@ -80,7 +70,6 @@ All heuristic durations are configurable rather than frozen paper rules:
--short-vocalization-max-seconds --short-vocalization-max-seconds
--continuation-min-seconds --continuation-min-seconds
--int-offset-tolerance-seconds --int-offset-tolerance-seconds
--wearer-resume-merge-max-seconds
``` ```
Use `--max-candidates 1` for a one-event smoke test. `summary.json` records the Use `--max-candidates 1` for a one-event smoke test. `summary.json` records the
@@ -90,7 +79,7 @@ timeline; it is not sent to DeepSeek.
## 2. Add DeepSeek pre-labels ## 2. Add DeepSeek pre-labels
Prompt v11 first reconstructs the exact participant-resolved chronology and Prompt v14 first reconstructs the exact participant-resolved chronology and
then uses transcript content and `Target_of_Speech` to determine conversational then uses transcript content and `Target_of_Speech` to determine conversational
function. It treats the EasyCom transcript marker `[L]` as laughter-only evidence, function. It treats the EasyCom transcript marker `[L]` as laughter-only evidence,
not as a lexical floor take. Wearer laughter during another participant's not as a lexical floor take. Wearer laughter during another participant's
@@ -98,8 +87,8 @@ retained turn is directed toward `ENTRY_WEARER_BACKCHANNEL`; non-wearer laughter
overlapping a retained wearer turn is directed toward `INT_OTHER_BACKCHANNEL`. overlapping a retained wearer turn is directed toward `INT_OTHER_BACKCHANNEL`.
Ambiguous laughter falls back to `EXCLUDE`, and `[L]` alone is neither evidence Ambiguous laughter falls back to `EXCLUDE`, and `[L]` alone is neither evidence
of a floor take nor evidence of a visible smile. Preceding release options are of a floor take nor evidence of a visible smile. Preceding release options are
optional entry metadata, whereas strict INT ordering remains a deterministic optional entry metadata. No entry/within-turn or INT/takeover semantic decision
retrieval prerequisite. A positive `WEARER_ENTRY` may optionally select one is made before the prompt. A positive `WEARER_ENTRY` may optionally select one
supplied release; entries from silence or during other activity use `null`. supplied release; entries from silence or during other activity use `null`.
`needs_human_review` is not requested from DeepSeek: every pre-label enters the `needs_human_review` is not requested from DeepSeek: every pre-label enters the
@@ -135,10 +124,14 @@ transcript together. It returns one of `POSITIVE`, `HARD_NEGATIVE`, or
`WEARER_ENTRY` or `INT_YIELD`, and hard negatives receive a named subtype. It `WEARER_ENTRY` or `INT_YIELD`, and hard negatives receive a named subtype. It
cannot modify timestamps. The output preserves the original rule candidate, cannot modify timestamps. The output preserves the original rule candidate,
the selected preceding-release UID when applicable, and the complete AI the selected preceding-release UID when applicable, and the complete AI
decision/provenance. `EXCLUDE` is the sole non-scoring outcome. It covers decision/provenance. `EXCLUDE` is the sole non-scoring outcome, but it is never
out-of-scope non-events (including a normal wearer completion that merely an untyped bucket. Every AI and human exclusion stores one closed-vocabulary
coincides with feedback), insufficient evidence, unresolvable data quality, or `exclude_reason` and the derived `exclude_reason_group`. `OUT_OF_SCOPE_*` means
disagreement between the two model passes. the annotations are valid but the behavior is outside the scored target;
`INSUFFICIENT_EVIDENCE` means the supplied valid material cannot resolve
conversational function; and `DATA_*` identifies unusable annotation,
recording, context, artifact, or timestamp quality. Model-pass disagreement is
recorded as `INSUFFICIENT_EVIDENCE`, never as data corruption.
Hard negatives use behavior-specific subtypes. ENTRY distinguishes a same-other Hard negatives use behavior-specific subtypes. ENTRY distinguishes a same-other
mid-turn pause, a different-other transition, and a wearer backchannel that mid-turn pause, a different-other transition, and a wearer backchannel that
@@ -156,8 +149,9 @@ all candidate/pass work, including the current pass, batch, percentage, and
elapsed time. Redirected logs emit periodic progress lines instead of carriage elapsed time. Redirected logs emit periodic progress lines instead of carriage
return animation. return animation.
As defense in depth, the adjudicator also detects and skips non-crossing Every current `WEARER_ONSET` and `WEARER_OFFSET` row is sent for semantic
`WEARER_OFFSET` rows from older v2 candidate files before making any API call. adjudication regardless of its timing pattern. Explicit control rows from older
protocol files remain excluded unless controls are intentionally reviewed.
## 3. Human review ## 3. Human review
@@ -192,22 +186,18 @@ decision, relabel flag, exclusion state, confidence, and provenance. The current
UI does not ask for a reviewer name; the export retains an empty `reviewer` field UI does not ask for a reviewer name; the export retains an empty `reviewer` field
only for compatibility with older review files. only for compatibility with older review files.
The primary review queue also filters legacy non-crossing `WEARER_OFFSET` rows The primary review queue filters only explicit legacy control rows. To audit
and explicit `WEARER_RESUME` controls. To audit the controls intentionally, those controls intentionally,
pass `--events .../easycom_eot_int_controls.jsonl --include-controls` and use a pass `--events .../easycom_eot_int_controls.jsonl --include-controls` and use a
separate review output file. The old `--include-offset-controls` spelling separate review output file. The old `--include-offset-controls` spelling
remains an alias. remains an alias.
Protocol wording for the paper appendix: Protocol wording for the paper appendix:
> Raw wearer speech onsets are first grouped into floor-level speech episodes > Every observed wearer speech onset and offset is retained as a semantic review
> by merging short within-turn resumptions that are not separated by a > candidate. Deterministic construction records only participant-resolved timing,
> competing floor takeover. Every resulting floor-level 0→1 transition is > transcript, and target-of-speech facts; it does not infer within-turn
> proposed as a wearer-entry candidate; a preceding non-wearer release is > resumption, floor entry, interruption, or takeover.
> retained as context rather than required for positive status.
> The original speech intervals are preserved unchanged for dense forecasting
> and auditability; this grouping is used only for behavioral event annotation.
## Freeze discipline ## Freeze discipline

44
easycom_event_taxonomy.py Normal file
View File

@@ -0,0 +1,44 @@
"""Closed vocabularies shared by EasyCom event pre-label and review tools."""
from __future__ import annotations
EXCLUDE_REASONS = {
"OUT_OF_SCOPE_NORMAL_COMPLETION",
"OUT_OF_SCOPE_WITHIN_TURN_CONTINUATION",
"OUT_OF_SCOPE_POST_COMPLETION_ACTIVITY",
"OUT_OF_SCOPE_NON_EVENT",
"INSUFFICIENT_EVIDENCE",
"DATA_ANNOTATION_ERROR",
"DATA_RECORDING_BOUNDARY",
"DATA_MISSING_CONTEXT",
"DATA_NON_SPEECH_ARTIFACT",
"DATA_TIMESTAMP_CORRUPTION",
}
# Accepted only when loading or editing old review records. New DeepSeek and UI
# outputs use the prefixed taxonomy above so scope and data-quality counts are
# never silently combined.
LEGACY_EXCLUDE_REASONS = {
"ANNOTATION_ERROR",
"RECORDING_BOUNDARY",
"MISSING_CONTEXT",
"NON_SPEECH_ARTIFACT",
"TIMESTAMP_CORRUPTION",
"UNRESOLVABLE",
}
def exclude_reason_group(reason: str | None) -> str | None:
"""Map a leaf reason to a stable aggregation group."""
if reason is None:
return None
if reason.startswith("OUT_OF_SCOPE_"):
return "OUT_OF_SCOPE"
if reason == "INSUFFICIENT_EVIDENCE":
return "INSUFFICIENT_EVIDENCE"
if reason.startswith("DATA_"):
return "DATA_QUALITY"
if reason in LEGACY_EXCLUDE_REASONS:
return "LEGACY_UNSPECIFIED"
return "UNKNOWN"

View File

@@ -57,13 +57,23 @@ const decisionMeanings = {
INT_WEARER_HOLDS_FLOOR: "Other 不是单纯 backchannel,且 wearer 在重叠后明确继续或恢复原 turn。", INT_WEARER_HOLDS_FLOOR: "Other 不是单纯 backchannel,且 wearer 在重叠后明确继续或恢复原 turn。",
OTHER_HARD_NEGATIVE: "这是可解释的困难负例,但不符合任何命名子类型;不能用于替代 Exclude。", OTHER_HARD_NEGATIVE: "这是可解释的困难负例,但不符合任何命名子类型;不能用于替代 Exclude。",
EXCLUDE: "该区域是无需参评的非目标事件,或存在证据/数据问题,不进入评分。", EXCLUDE: "该区域是无需参评的非目标事件,或存在证据/数据问题,不进入评分。",
OUT_OF_SCOPE_NORMAL_COMPLETION: "有效语境中的自然结束,不是 interruption yield。",
OUT_OF_SCOPE_WITHIN_TURN_CONTINUATION: "这是同一 wearer turn 内的暂停或继续,不是新的 floor event。",
OUT_OF_SCOPE_POST_COMPLETION_ACTIVITY: "Other 在 wearer 已结束后才开始,不能构成导致停止的 takeover。",
OUT_OF_SCOPE_NON_EVENT: "数据有效且可解释,但该边界不属于目标行为。",
INSUFFICIENT_EVIDENCE: "数据未显示明确损坏,但现有证据不足以可靠确定 conversational function。",
DATA_ANNOTATION_ERROR: "参与者、语音、转写或 target 标注明显错误或互相矛盾。",
DATA_RECORDING_BOUNDARY: "录制或 clip 边界使该事件无法可靠解释。",
DATA_MISSING_CONTEXT: "判断所需上下文未被提供。",
DATA_NON_SPEECH_ARTIFACT: "候选由非语音伪影造成。",
DATA_TIMESTAMP_CORRUPTION: "时间戳损坏或无法对齐。",
}; };
const currentAiVerdicts = new Set(["POSITIVE", "HARD_NEGATIVE", "EXCLUDE"]); const currentAiVerdicts = new Set(["POSITIVE", "HARD_NEGATIVE", "EXCLUDE"]);
function suggestionView(suggestion) { function suggestionView(suggestion) {
const verdict = suggestion.verdict || suggestion.protocol_disposition || "—"; const verdict = suggestion.verdict || suggestion.protocol_disposition || "—";
const label = suggestion.event_type || suggestion.subtype || suggestion.hard_negative_type || verdict; const label = suggestion.event_type || suggestion.subtype || suggestion.exclude_reason || suggestion.hard_negative_type || verdict;
const rawConfidence = suggestion.confidence ?? suggestion.final_confidence; const rawConfidence = suggestion.confidence ?? suggestion.final_confidence;
const numericConfidence = Number(rawConfidence); const numericConfidence = Number(rawConfidence);
const confidence = Number.isFinite(numericConfidence) const confidence = Number.isFinite(numericConfidence)
@@ -170,7 +180,7 @@ function applyFilters({ keepCurrent = true } = {}) {
(elements.splitFilter.value === "all" || item.split === elements.splitFilter.value) && (elements.splitFilter.value === "all" || item.split === elements.splitFilter.value) &&
(elements.familyFilter.value === "all" || item.family === elements.familyFilter.value) && (elements.familyFilter.value === "all" || item.family === elements.familyFilter.value) &&
(status === "all" || (status === "reviewed" ? reviewed : !reviewed)) && (status === "all" || (status === "reviewed" ? reviewed : !reviewed)) &&
(!search || `${item.uid} ${item.candidate_source || ""} ${item.rule_candidate_type || ""} ${item.ai_verdict || ""} ${item.ai_event_type || ""} ${item.ai_subtype || ""} ${item.session}`.toLowerCase().includes(search)) (!search || `${item.uid} ${item.candidate_source || ""} ${item.rule_candidate_type || ""} ${item.ai_verdict || ""} ${item.ai_event_type || ""} ${item.ai_subtype || ""} ${item.ai_exclude_reason || ""} ${item.session}`.toLowerCase().includes(search))
); );
}); });
renderQueue(); renderQueue();
@@ -206,7 +216,7 @@ function renderQueue() {
const label = document.createElement("div"); const label = document.createElement("div");
label.className = "queue-label"; label.className = "queue-label";
label.textContent = item.ai_verdict label.textContent = item.ai_verdict
? `AI ${item.ai_verdict} · ${item.ai_event_type || item.ai_subtype || "—"}` ? `AI ${item.ai_verdict} · ${item.ai_event_type || item.ai_subtype || item.ai_exclude_reason || "—"}`
: `${item.rule_candidate_type || item.automatic_label} · 待 AI 粗标`; : `${item.rule_candidate_type || item.automatic_label} · 待 AI 粗标`;
button.append(line, meta, label); button.append(line, meta, label);
button.addEventListener("click", () => selectCandidate(item.candidate_id)); button.addEventListener("click", () => selectCandidate(item.candidate_id));
@@ -292,7 +302,8 @@ function renderDetail(detail) {
const familyPrefix = detail.family === "entry" ? "ENTRY_" : detail.family === "int" ? "INT_" : null; const familyPrefix = detail.family === "entry" ? "ENTRY_" : detail.family === "int" ? "INT_" : null;
const canAccept = currentAiVerdicts.has(view.verdict) const canAccept = currentAiVerdicts.has(view.verdict)
&& (view.verdict !== "POSITIVE" || !familyEvent || suggestion.event_type === familyEvent) && (view.verdict !== "POSITIVE" || !familyEvent || suggestion.event_type === familyEvent)
&& (view.verdict !== "HARD_NEGATIVE" || !familyPrefix || suggestion.subtype === "OTHER_HARD_NEGATIVE" || String(suggestion.subtype || "").startsWith(familyPrefix)); && (view.verdict !== "HARD_NEGATIVE" || !familyPrefix || suggestion.subtype === "OTHER_HARD_NEGATIVE" || String(suggestion.subtype || "").startsWith(familyPrefix))
&& (view.verdict !== "EXCLUDE" || Boolean(suggestion.exclude_reason));
elements.acceptRadio.disabled = !canAccept; elements.acceptRadio.disabled = !canAccept;
elements.acceptTitle.textContent = canAccept elements.acceptTitle.textContent = canAccept
? `接受 ${view.verdict.replaceAll("_", " ")}` ? `接受 ${view.verdict.replaceAll("_", " ")}`
@@ -302,7 +313,9 @@ function renderDetail(detail) {
const anchorText = selectedOption ? ` · trigger ${formatTime(selectedOption.anchor_seconds)} P${selectedOption.participant_id}` : ""; const anchorText = selectedOption ? ` · trigger ${formatTime(selectedOption.anchor_seconds)} P${selectedOption.participant_id}` : "";
elements.acceptSummary.textContent = canAccept elements.acceptSummary.textContent = canAccept
? `${view.label}${anchorText} · ${view.meaning}` ? `${view.label}${anchorText} · ${view.meaning}`
: "该标签已移除,请改标为正例/硬负例,或选择 Exclude。"; : (view.verdict === "EXCLUDE"
? "旧 AI EXCLUDE 没有 reason taxonomy,请选择 Exclude 并补充原因。"
: "该标签已移除,请改标为正例/硬负例,或选择 Exclude。");
} else { } else {
elements.acceptRadio.disabled = true; elements.acceptRadio.disabled = true;
elements.acceptTitle.textContent = "没有 AI 结论"; elements.acceptTitle.textContent = "没有 AI 结论";

View File

@@ -88,7 +88,7 @@
<label id="subtypeField" hidden><span>Hard-negative subtype</span><select name="human_subtype"><option value="">请选择</option><option>ENTRY_OTHER_MIDTURN_PAUSE</option><option>ENTRY_OTHER_TO_OTHER</option><option>ENTRY_WEARER_BACKCHANNEL</option><option>INT_OTHER_BACKCHANNEL</option><option>INT_NON_TAKEOVER_OVERLAP</option><option>INT_WEARER_HOLDS_FLOOR</option><option>OTHER_HARD_NEGATIVE</option></select></label> <label id="subtypeField" hidden><span>Hard-negative subtype</span><select name="human_subtype"><option value="">请选择</option><option>ENTRY_OTHER_MIDTURN_PAUSE</option><option>ENTRY_OTHER_TO_OTHER</option><option>ENTRY_WEARER_BACKCHANNEL</option><option>INT_OTHER_BACKCHANNEL</option><option>INT_NON_TAKEOVER_OVERLAP</option><option>INT_WEARER_HOLDS_FLOOR</option><option>OTHER_HARD_NEGATIVE</option></select></label>
</section> </section>
<section id="excludeFields" class="conditional-fields" hidden><label><span>排除原因</span><select name="exclude_reason"><option value="">请选择</option><option value="OUT_OF_SCOPE_NORMAL_COMPLETION">OUT_OF_SCOPE_NORMAL_COMPLETION — wearer 正常说完</option><option value="OUT_OF_SCOPE_NON_EVENT">OUT_OF_SCOPE_NON_EVENT — 其他无需参评事件</option><option>ANNOTATION_ERROR</option><option>RECORDING_BOUNDARY</option><option>MISSING_CONTEXT</option><option>NON_SPEECH_ARTIFACT</option><option>TIMESTAMP_CORRUPTION</option><option>UNRESOLVABLE</option></select></label></section> <section id="excludeFields" class="conditional-fields" hidden><label><span>排除原因(分类统计)</span><select name="exclude_reason"><option value="">请选择</option><optgroup label="OUT OF SCOPE — 数据有效但不是目标行为"><option value="OUT_OF_SCOPE_NORMAL_COMPLETION">正常说完,没有 INT yield</option><option value="OUT_OF_SCOPE_WITHIN_TURN_CONTINUATION">同一 wearer turn 内暂停/续说</option><option value="OUT_OF_SCOPE_POST_COMPLETION_ACTIVITY">Other 在 wearer 结束后才开始</option><option value="OUT_OF_SCOPE_NON_EVENT">其他明确的非目标事件</option></optgroup><optgroup label="INSUFFICIENT EVIDENCE — 数据未坏但语义无法确定"><option value="INSUFFICIENT_EVIDENCE">现有证据不足以可靠判断</option></optgroup><optgroup label="DATA QUALITY — 数据或边界不可用"><option value="DATA_ANNOTATION_ERROR">标注明显错误或矛盾</option><option value="DATA_RECORDING_BOUNDARY">录制/clip 边界截断语境</option><option value="DATA_MISSING_CONTEXT">所需上下文缺失</option><option value="DATA_NON_SPEECH_ARTIFACT">非语音伪影</option><option value="DATA_TIMESTAMP_CORRUPTION">时间戳损坏或错位</option></optgroup></select></label></section>
<details class="boundary-details"><summary>仅在原 annotation 明显错误时校正边界</summary><label><span>可选校正(原时间始终保留)</span><select name="boundary_correction_seconds"><option value="">不校正</option><option value="-0.2">−0.2 s</option><option value="0.2">+0.2 s</option></select></label></details> <details class="boundary-details"><summary>仅在原 annotation 明显错误时校正边界</summary><label><span>可选校正(原时间始终保留)</span><select name="boundary_correction_seconds"><option value="">不校正</option><option value="-0.2">−0.2 s</option><option value="0.2">+0.2 s</option></select></label></details>

View File

@@ -233,14 +233,16 @@ participant timeline is retained to derive committed interaction events for
behavioral evaluation. behavioral evaluation.
\textbf{Behavioral event annotation.} \textbf{Behavioral event annotation.}
We use floor-level wearer transitions as high-recall anchors for positive-event We use every observed wearer activity transition as a high-recall semantic
annotation. Each wearer onset is examined retrospectively for a preceding candidate. For each wearer onset, nearby preceding non-wearer releases, the
non-wearer floor release as contextual metadata and labeled as a wearer-entry intervening silence gap, and concurrent activity are retained as contextual
whenever the subsequent wearer speech constitutes a genuine floor take, facts; the onset is labeled as wearer entry only when the subsequent wearer
including re-entry while another speaker is active. Similarly, each wearer offset speech constitutes a genuine floor take, including re-entry while another
is examined for a new competing non-wearer onset during the current wearer turn speaker is active. For each wearer offset, nearby non-wearer onsets and offsets,
and labeled as INT-yield only when the competing speaker genuinely takes over pre-existing and newly active speakers, overlap durations, and subsequent
the floor. We use permissive temporal retrieval and let an LLM jointly inspect speakers are retained without a timing-based takeover gate; the offset is
labeled as INT-yield only when a non-wearer genuinely takes over and the wearer
relinquishes the floor. We let an LLM jointly inspect
participant-resolved timing and transcripts to provide a semantic pre-label, participant-resolved timing and transcripts to provide a semantic pre-label,
followed by mandatory human adjudication. Separately, we mine regions with no followed by mandatory human adjudication. Separately, we mine regions with no
wearer response that nevertheless constitute false-positive opportunities: wearer response that nevertheless constitute false-positive opportunities:
@@ -272,7 +274,9 @@ HN: other backchannel & \todo{--} & \todo{--} \\
HN: non-takeover overlap & \todo{--} & \todo{--} \\ HN: non-takeover overlap & \todo{--} & \todo{--} \\
HN: wearer holds floor & \todo{--} & \todo{--} \\ HN: wearer holds floor & \todo{--} & \todo{--} \\
\hline \hline
Excluded / fallback & \todo{--} & \todo{--} \\ Excluded: valid but out of scope & \todo{--} & \todo{--} \\
Excluded: insufficient evidence & \todo{--} & \todo{--} \\
Excluded: data quality & \todo{--} & \todo{--} \\
\textbf{Scoring-eligible total} & \todo{--} & \todo{--} \\ \textbf{Scoring-eligible total} & \todo{--} & \todo{--} \\
\hline \hline
\end{tabular} \end{tabular}
@@ -557,42 +561,38 @@ because, by definition, many contain no wearer transition.
\subsection{Positive-event mining} \subsection{Positive-event mining}
\textbf{Floor-level transitions.} \textbf{Wearer activity transitions.}
Short wearer-silence gaps are conditionally merged before positive mining. In We preserve every raw wearer $0\!\rightarrow\!1$ and $1\!\rightarrow\!0$
the current audit configuration, a raw onset after at most 0.8~s of silence is boundary as a semantic review candidate. Candidate construction does not merge
treated as a within-turn resumption unless the gap contains sustained competing short silence gaps, infer within-turn resumption, or decide whether overlapping
non-wearer activity. Short feedback-like activity does not by itself split the speech constitutes takeover. It only records participant-resolved temporal and
wearer episode. This grouping changes only the behavioral-event layer, not the transcript facts. The source intervals and dense targets remain unchanged.
source intervals or dense targets.
\textbf{Wearer entry.} \textbf{Wearer entry.}
Every floor-level wearer onset at $t_b$ is proposed as a wearer-entry candidate. Every raw wearer onset at $t_b$ is proposed as one wearer-entry review item.
We additionally search backward for plausible non-wearer floor releases. A We search backward within the retrieval window for non-wearer offsets and store
release at $t_r$ is retained as a contextual option each one with its response delay and factual indicators of intervening wearer
only when the interval from $t_r$ to $t_b$ contains neither intervening wearer speech, intervening other onsets, and concurrent activity. These are possible
speech nor a competing non-wearer onset. Each wearer onset contributes at most context anchors rather than deterministically admissible causes. The LLM and
one review item: $t_b$ is its immutable \emph{behavior anchor}, and the item human reviewer decide whether the boundary is a genuine new entry, a
stores every admissible \emph{opportunity-anchor} option and response delay. within-turn resumption, a wearer backchannel, or an excluded non-event. A
Silence duration alone does not reject an option. A preceding release is not preceding release is not required for a positive entry: the wearer may enter
required for a positive entry: the wearer may enter after a release, from after a release, from silence, or while another speaker remains active. When a
silence, or while another speaker remains active. When a release clearly release clearly prompts the entry, the LLM or human may select one supplied
prompts the entry, the LLM or human may select one supplied option; neither may option; neither may invent or move a timestamp.
invent or move a trigger timestamp. The release relation is metadata for
response-oriented analyses, not part of the positive entry label.
\textbf{INT-yield.} \textbf{INT-yield.}
Starting from an episode-ending wearer offset $t_y$, we search backward inside Every raw wearer offset $t_y$ is proposed as an INT-yield review item. The
the current wearer episode for a new competing non-wearer onset $t_o$. With generator records the current wearer activity-run start, all nearby non-wearer
$t_w$ denoting the wearer-episode start, an INT candidate must satisfy onsets and offsets, activity immediately before and after $t_y$, whether an
\begin{equation} other speaker is pre-existing or begins during wearer speech, strict and
t_w < t_o < t_y < t_o^{\mathrm{off}}. 50-ms-tolerant boundary crossing, overlap duration, the next speech onset,
\label{eq:int_candidate} transcripts, and target-of-speech metadata. Relations such as
\end{equation} $t_w<t_o<t_y<t_o^{\mathrm{off}}$ are descriptive evidence only: they neither
The current audit allows a one-frame (50-ms) tolerance only at the offset admit nor reject a candidate and do not establish takeover. The LLM and human
boundary. Pre-existing other activity, post-completion onsets, offsets inside a annotator answer whether the wearer stopped because a non-wearer genuinely took
merged wearer episode, and completion-to-silence remain controls. Timing only over the floor. Natural completion, post-completion activity, and within-turn
retrieves a possible interruption; the LLM and human annotator decide whether pauses are therefore resolved semantically rather than pre-filtered.
the competitor genuinely takes over and the wearer yields.
\subsection{False-positive opportunity mining} \subsection{False-positive opportunity mining}
@@ -624,9 +624,11 @@ relabel it, or exclude an
unscorable region. Only human-confirmed \textsc{Wearer-Entry}, unscorable region. Only human-confirmed \textsc{Wearer-Entry},
\textsc{INT-Yield}, and hard-negative opportunities enter evaluation; \textsc{INT-Yield}, and hard-negative opportunities enter evaluation;
\textsc{Exclude} candidates do not. Thus \textsc{Exclude} is the sole \textsc{Exclude} candidates do not. Thus \textsc{Exclude} is the sole
non-scoring outcome for out-of-scope non-events (e.g., natural wearer completion non-scoring verdict, but every exclusion retains a closed-vocabulary reason and
that merely coincides with feedback), insufficient evidence, unresolvable data derived aggregation group. We report valid-but-out-of-scope behavior (e.g.,
quality, or pre-label disagreement. natural wearer completion coincident with feedback), insufficient semantic
evidence, and data-quality failures separately; model-pass disagreement belongs
to insufficient evidence and is never counted as corrupt data.
Annotation-only transcripts, identities, target-of-speech metadata, LLM output, Annotation-only transcripts, identities, target-of-speech metadata, LLM output,
and future observations are never supplied to the evaluated forecasting model. and future observations are never supplied to the evaluated forecasting model.

View File

@@ -2,13 +2,12 @@
"""Generate wearer-centric EasyCom entry/INT candidates for AI and human review. """Generate wearer-centric EasyCom entry/INT candidates for AI and human review.
This stage never changes EasyCom speech boundaries and never claims semantic This stage never changes EasyCom speech boundaries and never claims semantic
ground truth. It groups short wearer resumptions into floor-level episodes, ground truth. Every observed wearer onset and offset is retained as a semantic
starts entry candidates from every observed floor-level wearer onset and candidate. Deterministic code only records participant-resolved timing,
retains preceding non-wearer release options as context, requires a new transcripts, and target-of-speech facts; it never decides floor entry,
non-wearer onset inside the wearer floor episode for INT candidates, and keeps within-turn resumption, or takeover semantics. All timing windows are CLI
ineligible raw boundaries as deterministic controls. All timing parameters, and raw evidence is retained for DeepSeek pre-labeling and human
thresholds are CLI parameters, and raw evidence is retained for DeepSeek correction.
pre-labeling and human correction.
""" """
from __future__ import annotations from __future__ import annotations
@@ -28,8 +27,8 @@ from derive_easycom_interaction_events import (
from easycom_manifest import ALL_SESSIONS, TEST_SESSIONS from easycom_manifest import ALL_SESSIONS, TEST_SESSIONS
SCHEMA_VERSION = 5 SCHEMA_VERSION = 6
PROTOCOL_VERSION = "easycom-wearer-entry-int-candidates-v7" PROTOCOL_VERSION = "easycom-wearer-entry-int-candidates-v8"
@dataclass(frozen=True) @dataclass(frozen=True)
@@ -41,7 +40,6 @@ class CandidateConfig:
short_vocalization_max_seconds: float = 1.0 short_vocalization_max_seconds: float = 1.0
continuation_min_seconds: float = 0.4 continuation_min_seconds: float = 0.4
int_offset_tolerance_seconds: float = 0.05 int_offset_tolerance_seconds: float = 0.05
wearer_resume_merge_max_seconds: float = 0.8
@dataclass(frozen=True) @dataclass(frozen=True)
@@ -88,26 +86,13 @@ class ProtocolCandidate:
def requires_semantic_adjudication(row: dict[str, Any]) -> bool: def requires_semantic_adjudication(row: dict[str, Any]) -> bool:
"""Return whether a stored row belongs in the primary AI/human queue. """Return whether a stored row belongs in the primary AI/human queue.
Explicit legacy control sources are authoritative. For old v2 candidate Explicit legacy control sources are authoritative. Current WEARER_OFFSET
files, reconstruct the same-speaker crossing requirement from the stored rows are always semantic candidates: crossing/order fields are evidence,
activity IDs. Missing legacy evidence remains reviewable rather than being never admission gates.
silently discarded.
""" """
source = row.get("candidate_source") source = row.get("candidate_source")
if source in {"WEARER_ONSET_CONTROL", "WEARER_OFFSET_CONTROL", "WEARER_RESUME"}: if source in {"WEARER_ONSET_CONTROL", "WEARER_OFFSET_CONTROL", "WEARER_RESUME"}:
return False return False
if source != "WEARER_OFFSET":
return True
evidence = row.get("temporal_evidence")
if not isinstance(evidence, dict):
return True
explicit = evidence.get("other_crosses_wearer_offset")
if isinstance(explicit, bool):
return explicit
before = evidence.get("active_other_ids_before_offset")
after = evidence.get("active_other_ids_after_offset")
if isinstance(before, list) and isinstance(after, list):
return bool(set(before) & set(after))
return True return True
@@ -157,7 +142,7 @@ def _other_ids_crossing_offset(
) -> tuple[int, ...]: ) -> tuple[int, ...]:
"""Return other speakers whose annotated interval crosses a wearer offset. """Return other speakers whose annotated interval crosses a wearer offset.
With zero tolerance this encodes the strict prerequisite With zero tolerance this records the strict temporal relation
``other.start < wearer_offset < other.end``. A one-frame tolerance accepts ``other.start < wearer_offset < other.end``. A one-frame tolerance accepts
an annotation boundary at the same 20 Hz frame, but not an other onset one an annotation boundary at the same 20 Hz frame, but not an other onset one
or more frames after the wearer offset. or more frames after the wearer offset.
@@ -170,26 +155,19 @@ def _other_ids_crossing_offset(
})) }))
def _wearer_gap_competition( def _wearer_gap_evidence(
rows: Sequence[Utterance], *, gap_start_frame: int, onset_frame: int, rows: Sequence[Utterance], *, gap_start_frame: int, onset_frame: int,
wearer_id: int, short_frames: int, continuation_frames: int, wearer_id: int,
) -> dict[str, Any]: ) -> dict[str, Any]:
"""Summarize plausible competing floor activity inside a short wearer gap. """Return descriptive other-speaker facts within a wearer-silence gap."""
Feedback-like short lexical/nonverbal activity is retained as evidence but
does not by itself split the wearer floor episode. Any other contribution
that occupies the gap for ``continuation_frames`` or continues across the
resumed wearer onset conservatively preserves a new ENTRY candidate.
"""
gap_rows = [ gap_rows = [
row for row in rows row for row in rows
if row.participant_id != wearer_id if row.participant_id != wearer_id
and row.dense_start_frame < onset_frame and row.dense_start_frame < onset_frame
and row.dense_end_frame_exclusive > gap_start_frame and row.dense_end_frame_exclusive > gap_start_frame
] ]
feedback_ids: set[int] = set()
competing_ids: set[int] = set()
max_gap_coverage = 0 max_gap_coverage = 0
intervals: list[dict[str, Any]] = []
for row in gap_rows: for row in gap_rows:
coverage = max( coverage = max(
0, 0,
@@ -197,20 +175,20 @@ def _wearer_gap_competition(
- max(row.dense_start_frame, gap_start_frame), - max(row.dense_start_frame, gap_start_frame),
) )
max_gap_coverage = max(max_gap_coverage, coverage) max_gap_coverage = max(max_gap_coverage, coverage)
feedback_like = ( intervals.append({
row.dense_end_frame_exclusive - row.dense_start_frame <= short_frames "uid": row.uid,
and (is_lexical_feedback(row.transcription) or is_pure_nonverbal(row.transcription)) "participant_id": row.participant_id,
) "onset_seconds": row.dense_start_seconds,
if feedback_like: "offset_seconds": row.dense_end_seconds,
feedback_ids.add(row.participant_id) "gap_overlap_seconds": coverage / SOURCE_FPS,
elif coverage >= continuation_frames or row.dense_end_frame_exclusive > onset_frame: "continues_across_wearer_onset": row.dense_end_frame_exclusive > onset_frame,
competing_ids.add(row.participant_id) "transcription": row.transcription,
"target_of_speech": list(row.target_of_speech),
})
return { return {
"gap_other_participant_ids": sorted({row.participant_id for row in gap_rows}), "gap_other_participant_ids": sorted({row.participant_id for row in gap_rows}),
"feedback_like_other_ids_during_gap": sorted(feedback_ids), "gap_other_intervals": intervals,
"sustained_competing_other_ids_during_gap": sorted(competing_ids),
"max_other_gap_coverage_seconds": max_gap_coverage / SOURCE_FPS, "max_other_gap_coverage_seconds": max_gap_coverage / SOURCE_FPS,
"sustained_other_takeover_during_gap": bool(competing_ids),
} }
@@ -327,7 +305,6 @@ def derive_protocol_candidates(utterances: Sequence[Utterance], clips: Sequence[
short_frames = round(cfg.short_vocalization_max_seconds * SOURCE_FPS) short_frames = round(cfg.short_vocalization_max_seconds * SOURCE_FPS)
continuation_frames = round(cfg.continuation_min_seconds * SOURCE_FPS) continuation_frames = round(cfg.continuation_min_seconds * SOURCE_FPS)
int_offset_tolerance_frames = round(cfg.int_offset_tolerance_seconds * SOURCE_FPS) int_offset_tolerance_frames = round(cfg.int_offset_tolerance_seconds * SOURCE_FPS)
wearer_resume_merge_frames = round(cfg.wearer_resume_merge_max_seconds * SOURCE_FPS)
rows_by_segment: dict[int, list[Utterance]] = defaultdict(list) rows_by_segment: dict[int, list[Utterance]] = defaultdict(list)
clips_by_segment: dict[int, list[Clip]] = defaultdict(list) clips_by_segment: dict[int, list[Clip]] = defaultdict(list)
for row in utterances: for row in utterances:
@@ -352,10 +329,8 @@ def derive_protocol_candidates(utterances: Sequence[Utterance], clips: Sequence[
segment_end=segment_end, wearer_id=wearer_id) segment_end=segment_end, wearer_id=wearer_id)
other_rows = [row for row in rows if row.participant_id != wearer_id] other_rows = [row for row in rows if row.participant_id != wearer_id]
# A: classify raw wearer onsets before proposing any behavioral event. # A: retain every raw wearer onset and offset. Gap and nearby-speaker
# Short uncompeted gaps are internal to a floor episode. All other raw # properties are evidence only; they never suppress a semantic item.
# onsets begin a new floor episode and are all entry candidates. A
# preceding non-wearer release is optional context, not a gate.
onset_records: dict[int, dict[str, Any]] = {} onset_records: dict[int, dict[str, Any]] = {}
raw_offsets: list[int] = [] raw_offsets: list[int] = []
for index in range(1, len(wearer)): for index in range(1, len(wearer)):
@@ -365,25 +340,16 @@ def derive_protocol_candidates(utterances: Sequence[Utterance], clips: Sequence[
previous_wearer_index = index - gap_frames - 1 previous_wearer_index = index - gap_frames - 1
has_previous_wearer = previous_wearer_index >= 0 and bool(wearer[previous_wearer_index]) has_previous_wearer = previous_wearer_index >= 0 and bool(wearer[previous_wearer_index])
previous_wearer_offset = segment_start + index - gap_frames previous_wearer_offset = segment_start + index - gap_frames
competition = _wearer_gap_competition( gap_evidence = _wearer_gap_evidence(
rows, rows,
gap_start_frame=previous_wearer_offset, gap_start_frame=previous_wearer_offset,
onset_frame=frame, onset_frame=frame,
wearer_id=wearer_id, wearer_id=wearer_id,
short_frames=short_frames,
continuation_frames=continuation_frames,
) if has_previous_wearer else { ) if has_previous_wearer else {
"gap_other_participant_ids": [], "gap_other_participant_ids": [],
"feedback_like_other_ids_during_gap": [], "gap_other_intervals": [],
"sustained_competing_other_ids_during_gap": [],
"max_other_gap_coverage_seconds": 0.0, "max_other_gap_coverage_seconds": 0.0,
"sustained_other_takeover_during_gap": False,
} }
within_turn_resume = (
has_previous_wearer
and gap_frames <= wearer_resume_merge_frames
and not competition["sustained_other_takeover_during_gap"]
)
evidence = _relation_evidence(rows, frame=frame, wearer_id=wearer_id, evidence = _relation_evidence(rows, frame=frame, wearer_id=wearer_id,
window_frames=relation_frames) window_frames=relation_frames)
evidence.update( evidence.update(
@@ -392,166 +358,112 @@ def derive_protocol_candidates(utterances: Sequence[Utterance], clips: Sequence[
previous_wearer_offset_seconds=( previous_wearer_offset_seconds=(
previous_wearer_offset / SOURCE_FPS if has_previous_wearer else None previous_wearer_offset / SOURCE_FPS if has_previous_wearer else None
), ),
wearer_resume_merge_max_seconds=cfg.wearer_resume_merge_max_seconds,
floor_episode_merged=within_turn_resume,
original_timestamp_seconds=frame / SOURCE_FPS, original_timestamp_seconds=frame / SOURCE_FPS,
**competition, **gap_evidence,
) )
onset_records[frame] = { onset_records[frame] = {"evidence": evidence}
"within_turn_resume": within_turn_resume,
"evidence": evidence,
}
elif wearer[index - 1] and not wearer[index]: elif wearer[index - 1] and not wearer[index]:
raw_offsets.append(frame) raw_offsets.append(frame)
resume_onsets = { # A1: every observed raw wearer onset is an unresolved entry candidate.
frame for frame, record in onset_records.items() if record["within_turn_resume"] # Every nearby preceding other offset is supplied with factual relation
} # flags; no release is declared admissible or causal by this stage.
floor_onsets = sorted(set(onset_records) - resume_onsets) for onset_frame in sorted(onset_records):
for frame in sorted(resume_onsets):
record = onset_records[frame]
add(_candidate(
session=session, task="wearer_entry_control", source="WEARER_RESUME",
rule_type="WITHIN_TURN_RESUME_CONTROL", anchor_type="wearer_onset", frame=frame,
region_start=frame, region_end=min(segment_end, frame + relation_frames),
label="control", subtype=None,
previous_ids=_active_other_ids(rows, frame - 1, wearer_id=wearer_id),
incoming_ids=_active_other_ids(rows, frame, wearer_id=wearer_id),
evidence=record["evidence"], clips=segment_clips, rows=rows,
wearer_id=wearer_id, context_frames=context_frames,
reason="short raw wearer pause without sustained competing other takeover; merged into the existing floor episode",
), ("WEARER_RESUME", frame, ()))
# A1: every observed floor-level wearer onset is an entry candidate.
# Search backward for release anchors only to preserve response context;
# no preceding release is required for a positive WEARER_ENTRY.
for onset_frame in floor_onsets:
release_options: list[tuple[Utterance, int]] = [] release_options: list[tuple[Utterance, int]] = []
for release in other_rows: for release in other_rows:
opportunity_frame = release.dense_end_frame_exclusive opportunity_frame = release.dense_end_frame_exclusive
if opportunity_frame > onset_frame: if opportunity_frame > onset_frame or onset_frame - opportunity_frame > relation_frames:
continue continue
wearer_silent = ( release_options.append((release, opportunity_frame))
opportunity_frame == onset_frame
or _all_state(
wearer,
opportunity_frame - segment_start,
onset_frame - segment_start,
False,
)
)
competing_onset = any(
opportunity_frame <= row.dense_start_frame < onset_frame
for row in other_rows
)
other_already_holds = bool(
_active_other_ids(rows, opportunity_frame, wearer_id=wearer_id)
)
if wearer_silent and not competing_onset and not other_already_holds:
release_options.append((release, opportunity_frame))
release_options.sort( release_options.sort(
key=lambda item: (item[1], item[0].participant_id, item[0].uid) key=lambda item: (item[1], item[0].participant_id, item[0].uid)
) )
if release_options: earliest_opportunity_frame = (
_, latest_opportunity_frame = release_options[-1] release_options[0][1] if release_options else max(segment_start, onset_frame - relation_frames)
earliest_opportunity_frame = release_options[0][1] )
option_rows = [{ option_rows = []
for release, release_frame in release_options:
interval_left = release_frame - segment_start
interval_right = onset_frame - segment_start
option_rows.append({
"uid": release.uid, "uid": release.uid,
"anchor_type": "other_offset", "anchor_type": "other_offset",
"anchor_frame": frame, "anchor_frame": release_frame,
"anchor_seconds": frame / SOURCE_FPS, "anchor_seconds": release_frame / SOURCE_FPS,
"participant_id": release.participant_id, "participant_id": release.participant_id,
"transcription": release.transcription, "transcription": release.transcription,
"target_of_speech": list(release.target_of_speech), "target_of_speech": list(release.target_of_speech),
"response_delay_seconds": (onset_frame - frame) / SOURCE_FPS, "response_delay_seconds": (onset_frame - release_frame) / SOURCE_FPS,
} for release, frame in release_options] "wearer_active_between_release_and_onset": any(
evidence = dict(onset_records[onset_frame]["evidence"]) bool(wearer[i]) for i in range(max(0, interval_left), min(len(wearer), interval_right))
evidence.update(
opportunity_options=option_rows,
opportunity_anchor_type=None,
opportunity_anchor_frame=None,
opportunity_anchor_seconds=None,
behavior_anchor_type="wearer_floor_onset",
behavior_anchor_frame=onset_frame,
behavior_anchor_seconds=onset_frame / SOURCE_FPS,
minimum_response_delay_seconds=(onset_frame - latest_opportunity_frame) / SOURCE_FPS,
maximum_response_delay_seconds=(onset_frame - earliest_opportunity_frame) / SOURCE_FPS,
competing_other_onsets_before_behavior=[],
preceding_release_paired=True,
entry_context_timing=(
"OTHER_ACTIVE"
if _active_other_ids(rows, onset_frame, wearer_id=wearer_id)
else "AFTER_OTHER_RELEASE"
), ),
) "other_onset_between_release_and_onset": any(
previous_ids = tuple(sorted({row.participant_id for row, _ in release_options})) release_frame <= row.dense_start_frame < onset_frame for row in other_rows
add(_candidate( ),
session=session, task="wearer_entry", source="WEARER_ONSET", "other_active_at_wearer_onset": bool(
rule_type="WEARER_ENTRY_CANDIDATE", anchor_type="wearer_onset", _active_other_ids(rows, onset_frame, wearer_id=wearer_id)
frame=onset_frame, region_start=earliest_opportunity_frame, ),
region_end=min(segment_end, onset_frame + relation_frames), })
label="candidate", subtype=None, previous_ids=previous_ids, evidence = dict(onset_records[onset_frame]["evidence"])
incoming_ids=_active_other_ids(rows, onset_frame, wearer_id=wearer_id),
evidence=evidence, clips=segment_clips, rows=rows, wearer_id=wearer_id,
context_frames=context_frames,
reason="floor-level wearer onset; preceding release options retained as context",
), ("WEARER_ONSET", onset_frame, previous_ids))
continue
frame = onset_frame
evidence = dict(onset_records[frame]["evidence"])
evidence.update( evidence.update(
opportunity_options=[], opportunity_options=option_rows,
opportunity_anchor_type=None, opportunity_anchor_type=None,
opportunity_anchor_frame=None, opportunity_anchor_frame=None,
opportunity_anchor_seconds=None, opportunity_anchor_seconds=None,
behavior_anchor_type="wearer_floor_onset", behavior_anchor_type="wearer_raw_onset",
behavior_anchor_frame=frame, behavior_anchor_frame=onset_frame,
behavior_anchor_seconds=frame / SOURCE_FPS, behavior_anchor_seconds=onset_frame / SOURCE_FPS,
preceding_release_paired=False, preceding_release_options_present=bool(option_rows),
minimum_response_delay_seconds=(
None if not option_rows
else min(option["response_delay_seconds"] for option in option_rows)
),
maximum_response_delay_seconds=(
None if not option_rows
else max(option["response_delay_seconds"] for option in option_rows)
),
entry_context_timing=( entry_context_timing=(
"OTHER_ACTIVE" "OTHER_ACTIVE"
if _active_other_ids(rows, frame, wearer_id=wearer_id) if _active_other_ids(rows, onset_frame, wearer_id=wearer_id)
else "FROM_SILENCE_OR_UNRESOLVED" else "NO_OTHER_ACTIVE_AT_ONSET"
), ),
) )
previous_ids = _active_other_ids(rows, frame - 1, wearer_id=wearer_id) previous_ids = tuple(sorted({row.participant_id for row, _ in release_options}))
add(_candidate( add(_candidate(
session=session, task="wearer_entry", source="WEARER_ONSET", session=session, task="wearer_entry", source="WEARER_ONSET",
rule_type="WEARER_ENTRY_CANDIDATE", anchor_type="wearer_onset", frame=frame, rule_type="WEARER_ENTRY_CANDIDATE", anchor_type="wearer_onset", frame=onset_frame,
region_start=max(segment_start, frame - relation_frames), region_start=earliest_opportunity_frame,
region_end=min(segment_end, frame + relation_frames), region_end=min(segment_end, onset_frame + relation_frames),
label="candidate", subtype=None, label="candidate", subtype=None,
previous_ids=previous_ids, previous_ids=previous_ids,
incoming_ids=_active_other_ids(rows, frame, wearer_id=wearer_id), incoming_ids=_active_other_ids(rows, onset_frame, wearer_id=wearer_id),
evidence=evidence, clips=segment_clips, rows=rows, wearer_id=wearer_id, evidence=evidence, clips=segment_clips, rows=rows, wearer_id=wearer_id,
context_frames=context_frames, context_frames=context_frames,
reason="floor-level wearer onset without a surviving preceding release option", reason=(
), ("WEARER_ONSET", frame, previous_ids)) "raw wearer onset retained without floor-entry or within-turn-resumption gating; "
"nearby release relations are descriptive evidence"
),
), ("WEARER_ONSET", onset_frame, previous_ids))
# A2: a raw wearer offset is an INT candidate only at the end of a # A2: every raw wearer offset is an unresolved INT candidate. The code
# floor-level wearer episode and only if a new non-wearer onset obeys # records temporal relationships but does not infer takeover semantics.
# t_w < t_o < t_y and that same interval crosses the yield boundary. # In particular, new/pre-existing/post-offset other activity and
# All other offsets remain auditable controls. # boundary crossing are evidence for DeepSeek and the human reviewer,
ordered_raw_onsets = sorted(onset_records) # never deterministic admission gates.
current_floor_start = segment_start if wearer and wearer[0] else None current_activity_run_start = segment_start if wearer and wearer[0] else None
floor_start_by_offset: dict[int, tuple[int | None, bool]] = {} activity_run_start_by_offset: dict[int, int | None] = {}
for index in range(1, len(wearer)): for index in range(1, len(wearer)):
frame = segment_start + index frame = segment_start + index
if not wearer[index - 1] and wearer[index] and frame not in resume_onsets: if not wearer[index - 1] and wearer[index]:
current_floor_start = frame current_activity_run_start = frame
elif wearer[index - 1] and not wearer[index]: elif wearer[index - 1] and not wearer[index]:
next_onset = next((value for value in ordered_raw_onsets if value > frame), None) activity_run_start_by_offset[frame] = current_activity_run_start
internal_pause = next_onset in resume_onsets current_activity_run_start = None
floor_start_by_offset[frame] = (current_floor_start, internal_pause)
if not internal_pause:
current_floor_start = None
for frame in raw_offsets: for frame in raw_offsets:
index = frame - segment_start index = frame - segment_start
floor_start, internal_pause = floor_start_by_offset[frame] activity_run_start = activity_run_start_by_offset[frame]
strict_crossing_ids = _other_ids_crossing_offset( strict_crossing_ids = _other_ids_crossing_offset(
other_rows, frame=frame, wearer_id=wearer_id, tolerance_frames=0 other_rows, frame=frame, wearer_id=wearer_id, tolerance_frames=0
) )
@@ -559,71 +471,178 @@ def derive_protocol_candidates(utterances: Sequence[Utterance], clips: Sequence[
other_rows, frame=frame, wearer_id=wearer_id, other_rows, frame=frame, wearer_id=wearer_id,
tolerance_frames=int_offset_tolerance_frames, tolerance_frames=int_offset_tolerance_frames,
) )
eligible_rows = [ new_other_rows = [
row for row in other_rows row for row in other_rows
if floor_start is not None if activity_run_start is not None
and floor_start < row.dense_start_frame < frame and activity_run_start < row.dense_start_frame < frame
and row.dense_end_frame_exclusive > frame - int_offset_tolerance_frames
] ]
eligible_ids = tuple(sorted({row.participant_id for row in eligible_rows})) new_crossing_rows = [
preexisting_ids = tuple(sorted({ row for row in new_other_rows
row.participant_id for row in other_rows if row.dense_end_frame_exclusive > frame - int_offset_tolerance_frames
if floor_start is not None ]
and row.dense_start_frame <= floor_start new_other_ids = tuple(sorted({row.participant_id for row in new_other_rows}))
and row.dense_end_frame_exclusive > frame - int_offset_tolerance_frames new_crossing_ids = tuple(sorted({row.participant_id for row in new_crossing_rows}))
})) preexisting_rows = [
is_int_candidate = bool(eligible_ids) and not internal_pause row for row in other_rows
if activity_run_start is not None
and row.dense_start_frame <= activity_run_start
and row.dense_end_frame_exclusive > activity_run_start
]
preexisting_ids = tuple(sorted({row.participant_id for row in preexisting_rows}))
nearby_other_rows = [
row for row in other_rows
if row.dense_end_frame_exclusive > frame - relation_frames
and row.dense_start_frame < frame + relation_frames
]
nearby_other_intervals = [{
"uid": row.uid,
"participant_id": row.participant_id,
"onset_seconds": row.dense_start_seconds,
"offset_seconds": row.dense_end_seconds,
"onset_relative_to_wearer_offset_seconds": (
row.dense_start_frame - frame
) / SOURCE_FPS,
"offset_relative_to_wearer_offset_seconds": (
row.dense_end_frame_exclusive - frame
) / SOURCE_FPS,
"starts_during_wearer_activity_run": bool(
activity_run_start is not None
and activity_run_start < row.dense_start_frame < frame
),
"preexisting_at_wearer_activity_run_start": bool(
activity_run_start is not None
and row.dense_start_frame <= activity_run_start
and row.dense_end_frame_exclusive > activity_run_start
),
"active_immediately_before_offset": bool(
row.dense_start_frame <= frame - 1 < row.dense_end_frame_exclusive
),
"active_immediately_after_offset": bool(
row.dense_start_frame <= frame < row.dense_end_frame_exclusive
),
"strictly_crosses_offset": bool(
row.dense_start_frame < frame < row.dense_end_frame_exclusive
),
"crosses_offset_with_tolerance": bool(
row.dense_start_frame < frame + int_offset_tolerance_frames
and row.dense_end_frame_exclusive > frame - int_offset_tolerance_frames
),
"overlap_with_wearer_before_offset_seconds": (
0.0 if activity_run_start is None else max(
0, min(row.dense_end_frame_exclusive, frame)
- max(row.dense_start_frame, activity_run_start)
) / SOURCE_FPS
),
"transcription": row.transcription,
"target_of_speech": list(row.target_of_speech),
} for row in nearby_other_rows]
next_rows = sorted(
[
row for row in rows
if frame <= row.dense_start_frame <= frame + relation_frames
],
key=lambda row: (row.dense_start_frame, row.participant_id, row.uid),
)
first_next_frame = next_rows[0].dense_start_frame if next_rows else None
first_next_rows = (
[] if first_next_frame is None
else [row for row in next_rows if row.dense_start_frame == first_next_frame]
)
active_other_ids_after_offset = _active_other_ids(
rows, frame, wearer_id=wearer_id
)
next_speaker_ids = (
list(active_other_ids_after_offset)
if active_other_ids_after_offset
else [row.participant_id for row in first_next_rows]
)
next_speaker_roles = (
["other"] * len(active_other_ids_after_offset)
if active_other_ids_after_offset
else [
"wearer" if row.participant_id == wearer_id else "other"
for row in first_next_rows
]
)
evidence = _relation_evidence( evidence = _relation_evidence(
rows, frame=frame, wearer_id=wearer_id, window_frames=relation_frames rows, frame=frame, wearer_id=wearer_id, window_frames=relation_frames
) )
evidence.update( evidence.update(
wearer_floor_episode_start_frame=floor_start, wearer_activity_run_start_frame=activity_run_start,
wearer_floor_episode_start_seconds=( wearer_activity_run_start_seconds=(
None if floor_start is None else floor_start / SOURCE_FPS None if activity_run_start is None else activity_run_start / SOURCE_FPS
), ),
wearer_activity_run_duration_to_offset_seconds=(
None if activity_run_start is None
else (frame - activity_run_start) / SOURCE_FPS
),
wearer_offset_frame=frame,
wearer_offset_seconds=frame / SOURCE_FPS,
wearer_speech_duration_before_offset_seconds=_run_before(wearer, index, True) / SOURCE_FPS, wearer_speech_duration_before_offset_seconds=_run_before(wearer, index, True) / SOURCE_FPS,
wearer_silence_duration_after_offset_seconds=_run_after(wearer, index, False) / SOURCE_FPS, wearer_silence_duration_after_offset_seconds=_run_after(wearer, index, False) / SOURCE_FPS,
raw_offset_is_within_merged_floor_episode=internal_pause,
active_other_ids_before_offset=list(_active_other_ids(rows, frame - 1, wearer_id=wearer_id)), active_other_ids_before_offset=list(_active_other_ids(rows, frame - 1, wearer_id=wearer_id)),
active_other_ids_after_offset=list(_active_other_ids(rows, frame, wearer_id=wearer_id)), active_other_ids_after_offset=list(active_other_ids_after_offset),
strict_crossing_other_ids=list(strict_crossing_ids), strict_crossing_other_ids=list(strict_crossing_ids),
tolerance_crossing_other_ids=list(tolerance_crossing_ids), tolerance_crossing_other_ids=list(tolerance_crossing_ids),
eligible_new_other_onset_ids=list(eligible_ids), new_other_onset_ids_during_wearer=list(new_other_ids),
eligible_new_other_onsets=[{ new_other_onsets_during_wearer=[{
"participant_id": row.participant_id, "participant_id": row.participant_id,
"onset_seconds": row.dense_start_seconds, "onset_seconds": row.dense_start_seconds,
"offset_seconds": row.dense_end_seconds, "offset_seconds": row.dense_end_seconds,
"uid": row.uid, "uid": row.uid,
} for row in eligible_rows], } for row in new_other_rows],
preexisting_crossing_other_ids=list(preexisting_ids), new_other_onset_ids_crossing_offset=list(new_crossing_ids),
preexisting_other_ids_at_wearer_activity_run_start=list(preexisting_ids),
preexisting_other_intervals=[{
"participant_id": row.participant_id,
"onset_seconds": row.dense_start_seconds,
"offset_seconds": row.dense_end_seconds,
"uid": row.uid,
} for row in preexisting_rows],
nearby_other_intervals=nearby_other_intervals,
next_speech_onsets=[{
"participant_id": row.participant_id,
"participant_role": "wearer" if row.participant_id == wearer_id else "other",
"onset_seconds": row.dense_start_seconds,
"offset_seconds": row.dense_end_seconds,
"uid": row.uid,
"transcription": row.transcription,
"target_of_speech": list(row.target_of_speech),
} for row in next_rows],
next_speaker_ids=next_speaker_ids,
next_speaker_roles=next_speaker_roles,
next_speaker_basis=(
"active_across_offset"
if active_other_ids_after_offset
else ("first_new_onset_after_offset" if first_next_rows else None)
),
next_speech_onset_seconds=(
frame / SOURCE_FPS
if active_other_ids_after_offset
else (None if first_next_frame is None else first_next_frame / SOURCE_FPS)
),
other_crosses_wearer_offset=bool(tolerance_crossing_ids), other_crosses_wearer_offset=bool(tolerance_crossing_ids),
int_ordering_satisfied=bool(eligible_ids), new_other_onset_during_wearer=bool(new_other_rows),
new_other_onset_crosses_offset=bool(new_crossing_rows),
int_offset_tolerance_seconds=cfg.int_offset_tolerance_seconds, int_offset_tolerance_seconds=cfg.int_offset_tolerance_seconds,
original_timestamp_seconds=frame / SOURCE_FPS, original_timestamp_seconds=frame / SOURCE_FPS,
) )
control_reason = (
"raw wearer offset is internal to a merged floor episode"
if internal_pause
else "no new non-wearer onset satisfies t_w < t_o < t_y and crosses the wearer offset"
)
add(_candidate( add(_candidate(
session=session, session=session, task="int_yield", source="WEARER_OFFSET",
task="int_yield" if is_int_candidate else "int_yield_control", rule_type="YIELD_CANDIDATE",
source="WEARER_OFFSET" if is_int_candidate else "WEARER_OFFSET_CONTROL",
rule_type="YIELD_CANDIDATE" if is_int_candidate else "WEARER_OFFSET_CONTROL",
anchor_type="wearer_offset", frame=frame, anchor_type="wearer_offset", frame=frame,
region_start=max(segment_start, frame - relation_frames), region_start=max(segment_start, frame - relation_frames),
region_end=min(segment_end, frame + relation_frames), region_end=min(segment_end, frame + relation_frames),
label="candidate" if is_int_candidate else "control", subtype=None, label="candidate", subtype=None,
previous_ids=_active_other_ids(rows, frame - 1, wearer_id=wearer_id), previous_ids=_active_other_ids(rows, frame - 1, wearer_id=wearer_id),
incoming_ids=eligible_ids, incoming_ids=new_other_ids,
evidence=evidence, clips=segment_clips, rows=rows, wearer_id=wearer_id, evidence=evidence, clips=segment_clips, rows=rows, wearer_id=wearer_id,
context_frames=context_frames, context_frames=context_frames,
reason=( reason=(
"new non-wearer onset occurs inside the wearer floor episode and crosses the yield boundary; semantic takeover status is unresolved" "raw wearer offset retained without timing-based takeover gating; "
if is_int_candidate else control_reason "all nearby activity relations are descriptive evidence"
), ),
), ("WEARER_OFFSET" if is_int_candidate else "WEARER_OFFSET_CONTROL", frame, eligible_ids)) ), ("WEARER_OFFSET", frame, new_other_ids))
# B1: same speaker pauses and resumes while the wearer remains silent. # B1: same speaker pauses and resumes while the wearer remains silent.
by_speaker: dict[int, list[Utterance]] = defaultdict(list) by_speaker: dict[int, list[Utterance]] = defaultdict(list)
@@ -762,7 +781,10 @@ def parse_args() -> argparse.Namespace:
parser.add_argument("--short-vocalization-max-seconds", type=float, default=1.0) parser.add_argument("--short-vocalization-max-seconds", type=float, default=1.0)
parser.add_argument("--continuation-min-seconds", type=float, default=0.4) parser.add_argument("--continuation-min-seconds", type=float, default=0.4)
parser.add_argument("--int-offset-tolerance-seconds", type=float, default=0.05) parser.add_argument("--int-offset-tolerance-seconds", type=float, default=0.05)
parser.add_argument("--wearer-resume-merge-max-seconds", type=float, default=0.8) parser.add_argument(
"--wearer-resume-merge-max-seconds", type=float,
help=argparse.SUPPRESS,
) # Deprecated compatibility option; raw wearer boundaries are never merged.
parser.add_argument("--candidate-sources", nargs="+") parser.add_argument("--candidate-sources", nargs="+")
parser.add_argument("--max-candidates", type=int) parser.add_argument("--max-candidates", type=int)
parser.add_argument("--selection-seed", type=int, default=2027) parser.add_argument("--selection-seed", type=int, default=2027)
@@ -782,7 +804,6 @@ def main() -> None:
short_vocalization_max_seconds=args.short_vocalization_max_seconds, short_vocalization_max_seconds=args.short_vocalization_max_seconds,
continuation_min_seconds=args.continuation_min_seconds, continuation_min_seconds=args.continuation_min_seconds,
int_offset_tolerance_seconds=args.int_offset_tolerance_seconds, int_offset_tolerance_seconds=args.int_offset_tolerance_seconds,
wearer_resume_merge_max_seconds=args.wearer_resume_merge_max_seconds,
) )
dataset_root, output_dir = args.dataset_root.expanduser().resolve(), args.output_dir.expanduser().resolve() dataset_root, output_dir = args.dataset_root.expanduser().resolve(), args.output_dir.expanduser().resolve()
all_rows: list[ProtocolCandidate] = [] all_rows: list[ProtocolCandidate] = []

View File

@@ -27,6 +27,9 @@ from threading import Lock
from typing import Any, Iterable from typing import Any, Iterable
from urllib.parse import parse_qs, urlparse from urllib.parse import parse_qs, urlparse
from easycom_event_taxonomy import (
EXCLUDE_REASONS, LEGACY_EXCLUDE_REASONS, exclude_reason_group,
)
from prepare_easycom_eot_int_review import requires_semantic_adjudication from prepare_easycom_eot_int_review import requires_semantic_adjudication
@@ -34,7 +37,7 @@ SOURCE_FPS = 20.0
STATIC_ROOT = Path(__file__).resolve().parent / "floor_event_review_web" STATIC_ROOT = Path(__file__).resolve().parent / "floor_event_review_web"
VIDEO_ROOT_PARTS = ("Main", "Video_Compressed") VIDEO_ROOT_PARTS = ("Main", "Video_Compressed")
TRANSCRIPT_ROOT_PARTS = ("Main", "Speech_Transcriptions") TRANSCRIPT_ROOT_PARTS = ("Main", "Speech_Transcriptions")
REVIEW_SCHEMA_VERSION = 5 REVIEW_SCHEMA_VERSION = 6
MAX_REQUEST_BYTES = 128 * 1024 MAX_REQUEST_BYTES = 128 * 1024
PROTOCOL_DISPOSITION_VALUES = {"positive", "hard_negative", "exclude"} PROTOCOL_DISPOSITION_VALUES = {"positive", "hard_negative", "exclude"}
@@ -71,11 +74,7 @@ HUMAN_SUBTYPE_VALUES = {
"INT_OTHER_BACKCHANNEL", "INT_NON_TAKEOVER_OVERLAP", "INT_WEARER_HOLDS_FLOOR", "INT_OTHER_BACKCHANNEL", "INT_NON_TAKEOVER_OVERLAP", "INT_WEARER_HOLDS_FLOOR",
"OTHER_HARD_NEGATIVE", "OTHER_HARD_NEGATIVE",
} }
EXCLUDE_REASON_VALUES = { EXCLUDE_REASON_VALUES = EXCLUDE_REASONS | LEGACY_EXCLUDE_REASONS
"ANNOTATION_ERROR", "RECORDING_BOUNDARY", "MISSING_CONTEXT", "NON_SPEECH_ARTIFACT",
"TIMESTAMP_CORRUPTION", "OUT_OF_SCOPE_NORMAL_COMPLETION", "OUT_OF_SCOPE_NON_EVENT",
"UNRESOLVABLE",
}
@dataclass(frozen=True) @dataclass(frozen=True)
@@ -341,6 +340,8 @@ class CandidateRepository:
"ai_verdict": row.get("ai_verdict"), "ai_verdict": row.get("ai_verdict"),
"ai_event_type": row.get("ai_event_type"), "ai_event_type": row.get("ai_event_type"),
"ai_subtype": row.get("ai_subtype"), "ai_subtype": row.get("ai_subtype"),
"ai_exclude_reason": row.get("ai_exclude_reason"),
"ai_exclude_reason_group": row.get("ai_exclude_reason_group"),
"participant_role": row.get("participant_role") or row.get("holder_role"), "participant_role": row.get("participant_role") or row.get("holder_role"),
"event_time_seconds": event_time, "event_time_seconds": event_time,
"region_start_seconds": row.get("region_start_seconds", event_time), "region_start_seconds": row.get("region_start_seconds", event_time),
@@ -458,14 +459,19 @@ class ReviewStore:
selected_opportunity_uid = candidate.get("ai_selected_opportunity_uid") or ( selected_opportunity_uid = candidate.get("ai_selected_opportunity_uid") or (
candidate.get("llm_suggestion") or {} candidate.get("llm_suggestion") or {}
).get("selected_opportunity_uid") ).get("selected_opportunity_uid")
exclude_reason = candidate.get("ai_exclude_reason") or (
candidate.get("llm_suggestion") or {}
).get("exclude_reason")
if verdict not in HUMAN_VERDICT_VALUES: if verdict not in HUMAN_VERDICT_VALUES:
raise ValueError("Accept requires an AI pre-label; use Relabel when no AI label is available") raise ValueError("Accept requires an AI pre-label; use Relabel when no AI label is available")
elif action == "exclude": elif action == "exclude":
verdict, event_type, subtype = "EXCLUDE", None, None verdict, event_type, subtype = "EXCLUDE", None, None
selected_opportunity_uid = None selected_opportunity_uid = None
exclude_reason = payload.get("exclude_reason")
else: else:
verdict, event_type, subtype = payload.get("human_verdict"), payload.get("human_event_type"), payload.get("human_subtype") verdict, event_type, subtype = payload.get("human_verdict"), payload.get("human_event_type"), payload.get("human_subtype")
selected_opportunity_uid = payload.get("selected_opportunity_uid") selected_opportunity_uid = payload.get("selected_opportunity_uid")
exclude_reason = payload.get("exclude_reason")
if verdict not in HUMAN_VERDICT_VALUES: if verdict not in HUMAN_VERDICT_VALUES:
raise ValueError(f"Invalid human_verdict: {verdict!r}") raise ValueError(f"Invalid human_verdict: {verdict!r}")
if verdict == "POSITIVE" and event_type not in HUMAN_EVENT_TYPE_VALUES: if verdict == "POSITIVE" and event_type not in HUMAN_EVENT_TYPE_VALUES:
@@ -514,11 +520,12 @@ class ReviewStore:
candidate.get("event_time", candidate.get("dense_start_seconds")), candidate.get("event_time", candidate.get("dense_start_seconds")),
), ),
) )
exclude_reason = payload.get("exclude_reason") if verdict == "EXCLUDE" and exclude_reason not in EXCLUDE_REASON_VALUES:
if action == "exclude" and exclude_reason not in EXCLUDE_REASON_VALUES: raise ValueError(
raise ValueError("Exclude requires an exclude_reason") "EXCLUDE requires an exclude_reason; legacy AI exclusions must be manually categorized"
if action != "exclude" and exclude_reason is not None: )
raise ValueError("exclude_reason is only valid for Exclude") if verdict != "EXCLUDE" and exclude_reason is not None:
raise ValueError("exclude_reason is only valid for EXCLUDE")
correction = payload.get("boundary_correction_seconds") correction = payload.get("boundary_correction_seconds")
if correction not in {-0.2, 0, 0.2, None}: if correction not in {-0.2, 0, 0.2, None}:
raise ValueError("boundary_correction_seconds must be -0.2, 0, 0.2, or null") raise ValueError("boundary_correction_seconds must be -0.2, 0, 0.2, or null")
@@ -535,6 +542,7 @@ class ReviewStore:
else [selected_opportunity_seconds, behavior_anchor_seconds] else [selected_opportunity_seconds, behavior_anchor_seconds]
), ),
exclude_reason=exclude_reason, exclude_reason=exclude_reason,
exclude_reason_group=exclude_reason_group(exclude_reason),
relabeled=action == "relabel", relabeled=action == "relabel",
boundary_correction_seconds=correction, boundary_correction_seconds=correction,
original_event_time=candidate.get("event_time", candidate.get("anchor_dense_seconds")), original_event_time=candidate.get("event_time", candidate.get("anchor_dense_seconds")),
@@ -597,6 +605,8 @@ class ReviewStore:
"ai_verdict": candidate.payload.get("ai_verdict"), "ai_verdict": candidate.payload.get("ai_verdict"),
"ai_event_type": candidate.payload.get("ai_event_type"), "ai_event_type": candidate.payload.get("ai_event_type"),
"ai_subtype": candidate.payload.get("ai_subtype"), "ai_subtype": candidate.payload.get("ai_subtype"),
"ai_exclude_reason": candidate.payload.get("ai_exclude_reason"),
"ai_exclude_reason_group": candidate.payload.get("ai_exclude_reason_group"),
"review": normalized, "review": normalized,
"updated_at": datetime.now(timezone.utc).isoformat(), "updated_at": datetime.now(timezone.utc).isoformat(),
"candidate": candidate.payload, "candidate": candidate.payload,
@@ -650,9 +660,10 @@ class ReviewStore:
"automatic_protocol_label", "automatic_protocol_label",
"automatic_hard_negative_type", "automatic_hard_negative_type",
"ai_verdict", "ai_event_type", "ai_subtype", "ai_verdict", "ai_event_type", "ai_subtype",
"ai_exclude_reason", "ai_exclude_reason_group",
"human_action", "human_status", "human_verdict", "human_event_type", "human_subtype", "human_action", "human_status", "human_verdict", "human_event_type", "human_subtype",
"selected_opportunity_uid", "selected_opportunity_seconds", "selected_opportunity_uid", "selected_opportunity_seconds",
"valid_prediction_interval_seconds", "exclude_reason", "relabeled", "valid_prediction_interval_seconds", "exclude_reason", "exclude_reason_group", "relabeled",
"boundary_correction_seconds", "original_event_time", "boundary_correction_seconds", "original_event_time",
"confidence", "confidence",
"reviewer", "reviewer",

View File

@@ -8,7 +8,8 @@ import json
import tempfile import tempfile
from adjudicate_easycom_eot_int_candidates import ( from adjudicate_easycom_eot_int_candidates import (
PROMPT_VERSION, SYSTEM_PROMPT, _progress_line, _transport_rows, build_user_prompt, read_candidates, PROMPT_VERSION, SYSTEM_PROMPT, _progress_line, _transport_rows, build_user_prompt,
exclude_reason_group, read_candidates,
requires_semantic_adjudication, validate_decisions, requires_semantic_adjudication, validate_decisions,
) )
@@ -32,9 +33,10 @@ class EntryINTAdjudicationTests(unittest.TestCase):
self.assertIn("Yes.", prompt) self.assertIn("Yes.", prompt)
def test_system_prompt_treats_laughter_as_nonlexical_floor_feedback(self) -> None: def test_system_prompt_treats_laughter_as_nonlexical_floor_feedback(self) -> None:
self.assertEqual(PROMPT_VERSION, "easycom-wearer-entry-int-prelabel-v12") self.assertEqual(PROMPT_VERSION, "easycom-wearer-boundary-semantic-prelabel-v14")
self.assertIn("opportunity_options", SYSTEM_PROMPT) self.assertIn("opportunity_options", SYSTEM_PROMPT)
self.assertIn("t_w < t_o < t_y", SYSTEM_PROMPT) self.assertIn("t_w < t_o < t_y never proves takeover", SYSTEM_PROMPT)
self.assertIn("Every supplied WEARER_ONSET and WEARER_OFFSET is a raw observed", SYSTEM_PROMPT)
self.assertIn("a transcription consisting of [L] denotes laughter-only vocalization", SYSTEM_PROMPT) self.assertIn("a transcription consisting of [L] denotes laughter-only vocalization", SYSTEM_PROMPT)
self.assertIn("ENTRY_WEARER_BACKCHANNEL", SYSTEM_PROMPT) self.assertIn("ENTRY_WEARER_BACKCHANNEL", SYSTEM_PROMPT)
self.assertIn("INT_OTHER_BACKCHANNEL", SYSTEM_PROMPT) self.assertIn("INT_OTHER_BACKCHANNEL", SYSTEM_PROMPT)
@@ -45,11 +47,13 @@ class EntryINTAdjudicationTests(unittest.TestCase):
self.assertIn('"Nope." beginning 0.15 s after an addressed question ends', SYSTEM_PROMPT) self.assertIn('"Nope." beginning 0.15 s after an addressed question ends', SYSTEM_PROMPT)
self.assertIn("does not require a preceding non-wearer release", SYSTEM_PROMPT) self.assertIn("does not require a preceding non-wearer release", SYSTEM_PROMPT)
self.assertIn("duration alone never determines floor function", SYSTEM_PROMPT) self.assertIn("duration alone never determines floor function", SYSTEM_PROMPT)
self.assertIn("out-of-scope normal completion", SYSTEM_PROMPT) self.assertIn("normal wearer completion", SYSTEM_PROMPT)
self.assertIn("same incoming non-wearer actually takes", SYSTEM_PROMPT) self.assertIn("relevant non-wearer actually takes", SYSTEM_PROMPT)
self.assertIn("must not retroactively invalidate the preceding INT_YIELD", SYSTEM_PROMPT) self.assertIn("must not retroactively invalidate the preceding INT_YIELD", SYSTEM_PROMPT)
self.assertIn("do not infer that a visible smile occurred", SYSTEM_PROMPT) self.assertIn("do not infer that a visible smile occurred", SYSTEM_PROMPT)
self.assertIn("POSITIVE / INT_YIELD: subtype=null; selected_opportunity_uid=null", SYSTEM_PROMPT) self.assertIn("POSITIVE / INT_YIELD: subtype=null; exclude_reason=null", SYSTEM_PROMPT)
self.assertIn("OUT_OF_SCOPE_WITHIN_TURN_CONTINUATION", SYSTEM_PROMPT)
self.assertIn("DATA_TIMESTAMP_CORRUPTION", SYSTEM_PROMPT)
self.assertNotIn("NOT_APPLICABLE", SYSTEM_PROMPT) self.assertNotIn("NOT_APPLICABLE", SYSTEM_PROMPT)
self.assertNotIn("UNSURE", SYSTEM_PROMPT) self.assertNotIn("UNSURE", SYSTEM_PROMPT)
self.assertNotIn('"needs_human_review"', SYSTEM_PROMPT) self.assertNotIn('"needs_human_review"', SYSTEM_PROMPT)
@@ -78,7 +82,8 @@ class EntryINTAdjudicationTests(unittest.TestCase):
payload = { payload = {
"items": [{ "items": [{
"uid": "u1", "verdict": "EXCLUDE", "event_type": None, "uid": "u1", "verdict": "EXCLUDE", "event_type": None,
"subtype": None, "confidence": 0.4, "reason": "Ambiguous context.", "subtype": None, "exclude_reason": "INSUFFICIENT_EVIDENCE",
"confidence": 0.4, "reason": "Ambiguous context.",
"needs_human_review": "true", "needs_human_review": "true",
}] }]
} }
@@ -128,6 +133,9 @@ class EntryINTAdjudicationTests(unittest.TestCase):
"verdict": verdict, "verdict": verdict,
"event_type": event_type, "event_type": event_type,
"subtype": subtype, "subtype": subtype,
"exclude_reason": (
"INSUFFICIENT_EVIDENCE" if verdict == "EXCLUDE" else None
),
"selected_opportunity_uid": "copied-release", "selected_opportunity_uid": "copied-release",
"confidence": 0.8, "confidence": 0.8,
"reason": "Evidence-based decision.", "reason": "Evidence-based decision.",
@@ -139,6 +147,28 @@ class EntryINTAdjudicationTests(unittest.TestCase):
)[0] )[0]
self.assertIsNone(decision["selected_opportunity_uid"]) self.assertIsNone(decision["selected_opportunity_uid"])
def test_exclude_reason_taxonomy_separates_scope_evidence_and_data(self) -> None:
self.assertEqual(
exclude_reason_group("OUT_OF_SCOPE_NORMAL_COMPLETION"), "OUT_OF_SCOPE"
)
self.assertEqual(
exclude_reason_group("INSUFFICIENT_EVIDENCE"), "INSUFFICIENT_EVIDENCE"
)
self.assertEqual(
exclude_reason_group("DATA_ANNOTATION_ERROR"), "DATA_QUALITY"
)
base = {
"uid": "u1", "verdict": "EXCLUDE", "event_type": None,
"subtype": None, "selected_opportunity_uid": None,
"confidence": 0.4, "reason": "Not a scored event.",
}
with self.assertRaisesRegex(ValueError, "valid exclude_reason"):
validate_decisions({"items": [base]}, ["u1"])
with self.assertRaisesRegex(ValueError, "valid exclude_reason"):
validate_decisions(
{"items": [{**base, "exclude_reason": "ANNOTATION_ERROR"}]}, ["u1"]
)
def test_event_and_hard_negative_subtypes_match_candidate_family(self) -> None: def test_event_and_hard_negative_subtypes_match_candidate_family(self) -> None:
positive = { positive = {
"uid": "u1", "verdict": "POSITIVE", "event_type": "INT_YIELD", "uid": "u1", "verdict": "POSITIVE", "event_type": "INT_YIELD",
@@ -195,7 +225,7 @@ class EntryINTAdjudicationTests(unittest.TestCase):
self.assertEqual([row["uid"] for row in transported], ["item-0000", "item-0001"]) self.assertEqual([row["uid"] for row in transported], ["item-0000", "item-0001"])
self.assertEqual(mapping["item-0001"], rows[1]["uid"]) self.assertEqual(mapping["item-0001"], rows[1]["uid"])
def test_old_nonoverlap_wearer_offset_is_not_sent_for_adjudication(self) -> None: def test_every_current_wearer_offset_is_sent_for_adjudication(self) -> None:
control = { control = {
"candidate_source": "WEARER_OFFSET", "candidate_source": "WEARER_OFFSET",
"temporal_evidence": { "temporal_evidence": {
@@ -217,9 +247,9 @@ class EntryINTAdjudicationTests(unittest.TestCase):
"active_other_ids_after_offset": [4], "active_other_ids_after_offset": [4],
}, },
} }
self.assertFalse(requires_semantic_adjudication(control)) self.assertTrue(requires_semantic_adjudication(control))
self.assertTrue(requires_semantic_adjudication(crossing)) self.assertTrue(requires_semantic_adjudication(crossing))
self.assertFalse(requires_semantic_adjudication(speaker_change)) self.assertTrue(requires_semantic_adjudication(speaker_change))
self.assertFalse(requires_semantic_adjudication({"candidate_source": "WEARER_OFFSET_CONTROL"})) self.assertFalse(requires_semantic_adjudication({"candidate_source": "WEARER_OFFSET_CONTROL"}))
self.assertFalse(requires_semantic_adjudication({"candidate_source": "WEARER_ONSET_CONTROL"})) self.assertFalse(requires_semantic_adjudication({"candidate_source": "WEARER_ONSET_CONTROL"}))
self.assertFalse(requires_semantic_adjudication({"candidate_source": "WEARER_RESUME"})) self.assertFalse(requires_semantic_adjudication({"candidate_source": "WEARER_RESUME"}))

View File

@@ -106,36 +106,47 @@ class EntryINTReviewCandidateTests(unittest.TestCase):
self.assertEqual(onset.task, "wearer_entry") self.assertEqual(onset.task, "wearer_entry")
self.assertEqual(onset.automatic_protocol_label, "candidate") self.assertEqual(onset.automatic_protocol_label, "candidate")
self.assertEqual(onset.temporal_evidence["entry_context_timing"], "OTHER_ACTIVE") self.assertEqual(onset.temporal_evidence["entry_context_timing"], "OTHER_ACTIVE")
self.assertFalse(onset.temporal_evidence["preceding_release_paired"]) self.assertTrue(onset.temporal_evidence["preceding_release_options_present"])
self.assertTrue(
any(
option["other_onset_between_release_and_onset"]
for option in onset.temporal_evidence["opportunity_options"]
)
)
def test_short_wearer_pause_without_other_takeover_becomes_resume_control(self) -> None: def test_short_wearer_pause_still_becomes_raw_onset_candidate(self) -> None:
rows = [ rows = [
utterance("wearer-1", 2, 0.2, 1.0, "I was saying"), utterance("wearer-1", 2, 0.2, 1.0, "I was saying"),
utterance("wearer-2", 2, 1.5, 2.5, "and then this."), utterance("wearer-2", 2, 1.5, 2.5, "and then this."),
] ]
derived = self.derive(rows) derived = self.derive(rows)
resume = next(row for row in derived if row.candidate_source == "WEARER_RESUME") onset = next(
self.assertEqual(resume.task, "wearer_entry_control") row for row in derived
self.assertEqual(resume.rule_candidate_type, "WITHIN_TURN_RESUME_CONTROL") if row.candidate_source == "WEARER_ONSET" and row.anchor_dense_seconds == 1.5
self.assertEqual(resume.automatic_protocol_label, "control") )
self.assertTrue(resume.temporal_evidence["floor_episode_merged"]) self.assertEqual(onset.task, "wearer_entry")
self.assertAlmostEqual(resume.temporal_evidence["previous_wearer_silence_duration_seconds"], 0.5) self.assertEqual(onset.rule_candidate_type, "WEARER_ENTRY_CANDIDATE")
self.assertFalse(any( self.assertEqual(onset.automatic_protocol_label, "candidate")
row.candidate_source == "WEARER_ONSET" and row.anchor_dense_seconds == 1.5 self.assertAlmostEqual(onset.temporal_evidence["previous_wearer_silence_duration_seconds"], 0.5)
for row in derived self.assertFalse(any(row.candidate_source == "WEARER_RESUME" for row in derived))
))
def test_short_feedback_does_not_split_wearer_floor_episode(self) -> None: def test_gap_other_activity_is_recorded_without_feedback_semantics(self) -> None:
rows = [ rows = [
utterance("wearer-1", 2, 0.2, 1.0, "First clause"), utterance("wearer-1", 2, 0.2, 1.0, "First clause"),
utterance("feedback", 4, 1.1, 1.4, "Yeah."), utterance("feedback", 4, 1.1, 1.4, "Yeah."),
utterance("wearer-2", 2, 1.6, 2.5, "same turn continues."), utterance("wearer-2", 2, 1.6, 2.5, "same turn continues."),
] ]
resume = next(row for row in self.derive(rows) if row.candidate_source == "WEARER_RESUME") onset = next(
self.assertEqual(resume.temporal_evidence["feedback_like_other_ids_during_gap"], [4]) row for row in self.derive(rows)
self.assertFalse(resume.temporal_evidence["sustained_other_takeover_during_gap"]) if row.candidate_source == "WEARER_ONSET" and row.anchor_dense_seconds == 1.6
)
gap = onset.temporal_evidence["gap_other_intervals"]
self.assertEqual([item["participant_id"] for item in gap], [4])
self.assertEqual(gap[0]["transcription"], "Yeah.")
self.assertAlmostEqual(gap[0]["gap_overlap_seconds"], 0.3)
self.assertNotIn("feedback_like_other_ids_during_gap", onset.temporal_evidence)
def test_short_gap_with_sustained_other_takeover_preserves_positive_entry_candidate(self) -> None: def test_short_gap_with_other_activity_preserves_unresolved_entry_candidate(self) -> None:
rows = [ rows = [
utterance("wearer-1", 2, 0.2, 1.0, "First wearer turn."), utterance("wearer-1", 2, 0.2, 1.0, "First wearer turn."),
utterance("other", 4, 0.8, 2.2, "I have the floor now."), utterance("other", 4, 0.8, 2.2, "I have the floor now."),
@@ -146,16 +157,17 @@ class EntryINTReviewCandidateTests(unittest.TestCase):
row for row in derived row for row in derived
if row.candidate_source == "WEARER_ONSET" and row.anchor_dense_seconds == 1.6 if row.candidate_source == "WEARER_ONSET" and row.anchor_dense_seconds == 1.6
) )
self.assertFalse(reentry.temporal_evidence["floor_episode_merged"]) self.assertEqual(reentry.temporal_evidence["gap_other_participant_ids"], [4])
self.assertTrue(reentry.temporal_evidence["sustained_other_takeover_during_gap"]) self.assertTrue(
self.assertEqual(reentry.temporal_evidence["sustained_competing_other_ids_during_gap"], [4]) reentry.temporal_evidence["gap_other_intervals"][0]["continues_across_wearer_onset"]
)
self.assertEqual(reentry.temporal_evidence["entry_context_timing"], "OTHER_ACTIVE") self.assertEqual(reentry.temporal_evidence["entry_context_timing"], "OTHER_ACTIVE")
self.assertEqual(reentry.automatic_protocol_label, "candidate") self.assertEqual(reentry.automatic_protocol_label, "candidate")
preceding_yield = next( preceding_yield = next(
row for row in derived row for row in derived
if row.candidate_source == "WEARER_OFFSET" and row.anchor_dense_seconds == 1.0 if row.candidate_source == "WEARER_OFFSET" and row.anchor_dense_seconds == 1.0
) )
self.assertTrue(preceding_yield.temporal_evidence["int_ordering_satisfied"]) self.assertTrue(preceding_yield.temporal_evidence["new_other_onset_during_wearer"])
def test_same_other_resume_is_entry_midturn_hard_negative(self) -> None: def test_same_other_resume_is_entry_midturn_hard_negative(self) -> None:
rows = [ rows = [
@@ -199,32 +211,43 @@ class EntryINTReviewCandidateTests(unittest.TestCase):
self.assertEqual(event.anchor_type, "wearer_offset") self.assertEqual(event.anchor_type, "wearer_offset")
self.assertAlmostEqual(event.event_time, 2.0) self.assertAlmostEqual(event.event_time, 2.0)
self.assertEqual(event.temporal_evidence["tolerance_crossing_other_ids"], [4]) self.assertEqual(event.temporal_evidence["tolerance_crossing_other_ids"], [4])
self.assertEqual(event.temporal_evidence["eligible_new_other_onset_ids"], [4]) self.assertEqual(event.temporal_evidence["new_other_onset_ids_during_wearer"], [4])
self.assertAlmostEqual( self.assertAlmostEqual(
event.temporal_evidence["eligible_new_other_onsets"][0]["onset_seconds"], 1.0 event.temporal_evidence["new_other_onsets_during_wearer"][0]["onset_seconds"], 1.0
) )
self.assertTrue(event.temporal_evidence["int_ordering_satisfied"]) self.assertTrue(event.temporal_evidence["new_other_onset_during_wearer"])
self.assertTrue(event.temporal_evidence["new_other_onset_crosses_offset"])
self.assertEqual(event.temporal_evidence["next_speaker_ids"], [4])
self.assertEqual(event.temporal_evidence["next_speaker_basis"], "active_across_offset")
nearby = event.temporal_evidence["nearby_other_intervals"][0]
self.assertAlmostEqual(nearby["overlap_with_wearer_before_offset_seconds"], 1.0)
def test_preexisting_other_crossing_offset_is_not_an_int_candidate(self) -> None: def test_preexisting_other_crossing_offset_remains_an_int_candidate(self) -> None:
rows = [ rows = [
utterance("other", 4, 0.0, 3.0, "Other already holds the floor."), utterance("other", 4, 0.0, 3.0, "Other already holds the floor."),
utterance("wearer", 2, 1.0, 2.0, "Wearer enters and stops."), utterance("wearer", 2, 1.0, 2.0, "Wearer enters and stops."),
] ]
event = next(row for row in self.derive(rows) if row.anchor_type == "wearer_offset") event = next(row for row in self.derive(rows) if row.anchor_type == "wearer_offset")
self.assertEqual(event.candidate_source, "WEARER_OFFSET_CONTROL") self.assertEqual(event.candidate_source, "WEARER_OFFSET")
self.assertEqual(event.temporal_evidence["preexisting_crossing_other_ids"], [4]) self.assertEqual(event.task, "int_yield")
self.assertFalse(event.temporal_evidence["int_ordering_satisfied"]) self.assertEqual(
event.temporal_evidence["preexisting_other_ids_at_wearer_activity_run_start"], [4]
)
self.assertFalse(event.temporal_evidence["new_other_onset_during_wearer"])
self.assertTrue(event.temporal_evidence["other_crosses_wearer_offset"])
def test_other_starting_after_wearer_offset_is_a_separate_control(self) -> None: def test_other_starting_after_wearer_offset_remains_an_int_candidate(self) -> None:
rows = [ rows = [
utterance("wearer", 2, 0.0, 2.0, "Wearer finishes."), utterance("wearer", 2, 0.0, 2.0, "Wearer finishes."),
utterance("other", 4, 2.2, 3.0, "Later turn."), utterance("other", 4, 2.2, 3.0, "Later turn."),
] ]
event = next(row for row in self.derive(rows) if row.anchor_type == "wearer_offset") event = next(row for row in self.derive(rows) if row.anchor_type == "wearer_offset")
self.assertEqual(event.candidate_source, "WEARER_OFFSET_CONTROL") self.assertEqual(event.candidate_source, "WEARER_OFFSET")
self.assertEqual(event.rule_candidate_type, "WEARER_OFFSET_CONTROL") self.assertEqual(event.rule_candidate_type, "YIELD_CANDIDATE")
self.assertEqual(event.automatic_protocol_label, "control") self.assertEqual(event.automatic_protocol_label, "candidate")
self.assertFalse(event.temporal_evidence["other_crosses_wearer_offset"]) self.assertFalse(event.temporal_evidence["other_crosses_wearer_offset"])
self.assertEqual(event.temporal_evidence["next_speaker_ids"], [4])
self.assertAlmostEqual(event.temporal_evidence["next_speech_onset_seconds"], 2.2)
def test_one_frame_boundary_tolerance_is_configurable(self) -> None: def test_one_frame_boundary_tolerance_is_configurable(self) -> None:
rows = [ rows = [
@@ -241,9 +264,11 @@ class EntryINTReviewCandidateTests(unittest.TestCase):
tolerant_offset = next(row for row in tolerant if row.anchor_type == "wearer_offset") tolerant_offset = next(row for row in tolerant if row.anchor_type == "wearer_offset")
strict_offset = next(row for row in strict if row.anchor_type == "wearer_offset") strict_offset = next(row for row in strict if row.anchor_type == "wearer_offset")
self.assertEqual(tolerant_offset.candidate_source, "WEARER_OFFSET") self.assertEqual(tolerant_offset.candidate_source, "WEARER_OFFSET")
self.assertEqual(strict_offset.candidate_source, "WEARER_OFFSET_CONTROL") self.assertEqual(strict_offset.candidate_source, "WEARER_OFFSET")
self.assertTrue(tolerant_offset.temporal_evidence["other_crosses_wearer_offset"])
self.assertFalse(strict_offset.temporal_evidence["other_crosses_wearer_offset"])
def test_internal_raw_offset_is_not_an_int_candidate(self) -> None: def test_internal_raw_offset_remains_an_int_candidate(self) -> None:
rows = [ rows = [
utterance("wearer-1", 2, 0.0, 1.0, "First clause."), utterance("wearer-1", 2, 0.0, 1.0, "First clause."),
utterance("other", 4, 0.8, 1.3, "Yeah."), utterance("other", 4, 0.8, 1.3, "Yeah."),
@@ -253,8 +278,8 @@ class EntryINTReviewCandidateTests(unittest.TestCase):
row for row in self.derive(rows) row for row in self.derive(rows)
if row.anchor_type == "wearer_offset" and row.anchor_dense_seconds == 1.0 if row.anchor_type == "wearer_offset" and row.anchor_dense_seconds == 1.0
) )
self.assertEqual(event.candidate_source, "WEARER_OFFSET_CONTROL") self.assertEqual(event.candidate_source, "WEARER_OFFSET")
self.assertTrue(event.temporal_evidence["raw_offset_is_within_merged_floor_episode"]) self.assertEqual(event.automatic_protocol_label, "candidate")
def test_brief_feedback_is_int_backchannel_hard_negative(self) -> None: def test_brief_feedback_is_int_backchannel_hard_negative(self) -> None:
rows = [ rows = [

View File

@@ -147,7 +147,7 @@ class FloorEventReviewTests(unittest.TestCase):
[candidate.resolve()], [candidate.resolve()],
) )
def test_primary_queue_skips_legacy_nonoverlap_wearer_offset(self) -> None: def test_primary_queue_keeps_nonoverlap_wearer_offset_for_semantics(self) -> None:
with tempfile.TemporaryDirectory() as temporary: with tempfile.TemporaryDirectory() as temporary:
path = Path(temporary) / "legacy.jsonl" path = Path(temporary) / "legacy.jsonl"
rows = [ rows = [
@@ -171,7 +171,7 @@ class FloorEventReviewTests(unittest.TestCase):
) )
primary = load_candidates([path]) primary = load_candidates([path])
all_rows = load_candidates([path], include_offset_controls=True) all_rows = load_candidates([path], include_offset_controls=True)
self.assertEqual([row.uid for row in primary], [rows[1]["uid"]]) self.assertEqual([row.uid for row in primary], [row["uid"] for row in rows])
self.assertEqual(len(all_rows), 2) self.assertEqual(len(all_rows), 2)
def test_relabel_preserves_ai_and_records_human_decision(self) -> None: def test_relabel_preserves_ai_and_records_human_decision(self) -> None:
@@ -259,6 +259,37 @@ class FloorEventReviewTests(unittest.TestCase):
self.assertEqual( self.assertEqual(
record["review"]["exclude_reason"], "OUT_OF_SCOPE_NORMAL_COMPLETION" record["review"]["exclude_reason"], "OUT_OF_SCOPE_NORMAL_COMPLETION"
) )
self.assertEqual(record["review"]["exclude_reason_group"], "OUT_OF_SCOPE")
def test_accept_ai_exclude_preserves_reason_taxonomy(self) -> None:
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
_, events = self._fixture(root)
candidates = load_candidates([events])
candidate = candidates[0]
candidate.payload.update(
ai_verdict="EXCLUDE",
ai_event_type=None,
ai_subtype=None,
ai_exclude_reason="DATA_MISSING_CONTEXT",
ai_exclude_reason_group="DATA_QUALITY",
llm_suggestion={
"verdict": "EXCLUDE",
"event_type": None,
"subtype": None,
"exclude_reason": "DATA_MISSING_CONTEXT",
},
)
store = ReviewStore(root / "reviews.jsonl", candidates)
record = store.save(candidate.candidate_id, {
"human_action": "accept", "human_verdict": None,
"human_event_type": None, "human_subtype": None,
"selected_opportunity_uid": None, "exclude_reason": None,
"boundary_correction_seconds": None, "confidence": "medium",
"notes": "context is truncated",
})
self.assertEqual(record["review"]["exclude_reason"], "DATA_MISSING_CONTEXT")
self.assertEqual(record["review"]["exclude_reason_group"], "DATA_QUALITY")
def test_entry_review_may_select_only_a_supplied_opportunity_anchor(self) -> None: def test_entry_review_may_select_only_a_supplied_opportunity_anchor(self) -> None:
with tempfile.TemporaryDirectory() as temporary: with tempfile.TemporaryDirectory() as temporary: