diff --git a/adjudicate_easycom_eot_int_candidates.py b/adjudicate_easycom_eot_int_candidates.py index 22c1e83..9dc7a96 100644 --- a/adjudicate_easycom_eot_int_candidates.py +++ b/adjudicate_easycom_eot_int_candidates.py @@ -27,7 +27,7 @@ from easycom_event_taxonomy import EXCLUDE_REASONS, exclude_reason_group from prepare_easycom_eot_int_review import requires_semantic_adjudication -PROMPT_VERSION = "easycom-wearer-boundary-semantic-prelabel-v15" +PROMPT_VERSION = "easycom-wearer-boundary-semantic-prelabel-v16" DEFAULT_MODEL = "deepseek-v4-flash" DEFAULT_BASE_URL = "https://api.deepseek.com" VERDICTS = {"POSITIVE", "HARD_NEGATIVE", "EXCLUDE"} @@ -107,7 +107,11 @@ Evidence rules: Apply this adjacency-pair rule before using brevity or later speaker activity as evidence: when a participant addresses the wearer with a question, request, proposal, or other first-pair part and the wearer supplies its contextually complete second-pair part, classify the wearer contribution as POSITIVE / WEARER_ENTRY. A polar-question answer such as "yes", "no", or "Nope." is complete even if it is one word and lasts less than one second. Reciprocal Target_of_Speech evidence (question targets wearer; answer targets questioner) strengthens this reading. Floor entry here means making the committed conversational response; it does not require monopolizing or retaining the floor afterward. Therefore, the questioner or another participant speaking after the answer does not turn that completed answer into a backchannel. Use HARD_NEGATIVE / ENTRY_WEARER_BACKCHANNEL only when the wearer vocalization functions merely as feedback on another participant's still-ongoing contribution and does not answer a question, accept or reject a proposal, provide requested information, or otherwise complete an adjacency pair. Lexical form alone is insufficient: "yeah" may answer a yes/no question or may be a continuer, depending on the preceding utterance and addressee evidence. For the supplied audit pattern, participant 3 asks the wearer "Have you ever analyzed documents?" and releases at 54.00 s; the wearer answers "Nope." at 54.15 s. This is POSITIVE / WEARER_ENTRY, not ENTRY_WEARER_BACKCHANNEL, even though it is brief and participant 3 later speaks again. -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. +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. + + Before deciding an offset, determine from transcript and chronology whether the wearer activity run ending there ever held the floor. If it is only a wearer backchannel embedded in a non-wearer's already ongoing turn, then the non-wearer's continuation across its offset is neither a takeover nor evidence that the wearer held the floor. Return EXCLUDE / OUT_OF_SCOPE_WEARER_BACKCHANNEL_OFFSET. The backchannel is scored once, at its wearer-onset ENTRY candidate, as HARD_NEGATIVE / ENTRY_WEARER_BACKCHANNEL; do not count its trailing offset again as an INT hard negative. For the supplied audit pattern, participant 1 speaks from 320.25 to 325.50 s while the wearer says only "Awesome." from 323.05 to 323.80 s. The wearer onset is an ENTRY_WEARER_BACKCHANNEL opportunity, while the wearer offset is OUT_OF_SCOPE_WEARER_BACKCHANNEL_OFFSET, not INT_WEARER_HOLDS_FLOOR and not 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: - 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; @@ -121,6 +125,7 @@ Top-level verdicts: 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_WEARER_BACKCHANNEL_OFFSET: the wearer activity run is only a backchannel inside a non-wearer's retained turn; its onset carries the ENTRY hard-negative opportunity, while this trailing offset is not scored again as INT. - 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. diff --git a/docs/easycom_event_review_tool.md b/docs/easycom_event_review_tool.md index c84a5b5..ec9af71 100644 --- a/docs/easycom_event_review_tool.md +++ b/docs/easycom_event_review_tool.md @@ -164,7 +164,7 @@ non-backchannel overlap after which the wearer demonstrably holds or resumes the floor, and finally another interpretable overlap that fails to become a sustained takeover. `OTHER_HARD_NEGATIVE` is reserved for valid false-positive opportunities outside these named behaviors; it is never an uncertainty -fallback. Prompt v15 gives adjacency-pair completion priority over duration: +fallback. Prompt v16 gives adjacency-pair completion priority over duration: a short but complete answer to an addressed question remains a positive wearer entry, and floor entry does not require the wearer to retain the next turn. Later resumption by the questioner or another speaker therefore does not turn @@ -173,6 +173,15 @@ reserved for feedback that does not answer a question, accept or reject a proposal, provide requested information, or otherwise complete an adjacency pair. +The onset and offset of one wearer vocalization remain separate raw boundary +candidates, but they are not automatically two scoring opportunities. If the +wearer activity run is a backchannel inside a non-wearer's retained turn, its +onset may be retained once as `ENTRY_WEARER_BACKCHANNEL`; its trailing offset +is excluded as `OUT_OF_SCOPE_WEARER_BACKCHANNEL_OFFSET`, rather than being +counted again as an INT hard negative. This episode-level interpretation is +made by the LLM and human reviewer from timing and transcript evidence, not by +candidate-generation gating. + During live adjudication, the CLI displays a dependency-free progress bar over all candidate/pass work, including the current pass, batch, percentage, and elapsed time. Redirected logs emit periodic progress lines instead of carriage diff --git a/easycom_event_taxonomy.py b/easycom_event_taxonomy.py index 72eb336..b5011b5 100644 --- a/easycom_event_taxonomy.py +++ b/easycom_event_taxonomy.py @@ -5,6 +5,7 @@ from __future__ import annotations EXCLUDE_REASONS = { "OUT_OF_SCOPE_NORMAL_COMPLETION", + "OUT_OF_SCOPE_WEARER_BACKCHANNEL_OFFSET", "OUT_OF_SCOPE_WITHIN_TURN_CONTINUATION", "OUT_OF_SCOPE_POST_COMPLETION_ACTIVITY", "OUT_OF_SCOPE_NON_EVENT", diff --git a/floor_event_review_web/app.js b/floor_event_review_web/app.js index 494f886..49f9901 100644 --- a/floor_event_review_web/app.js +++ b/floor_event_review_web/app.js @@ -58,6 +58,7 @@ const decisionMeanings = { OTHER_HARD_NEGATIVE: "这是可解释的困难负例,但不符合任何命名子类型;不能用于替代 Exclude。", EXCLUDE: "该区域是无需参评的非目标事件,或存在证据/数据问题,不进入评分。", OUT_OF_SCOPE_NORMAL_COMPLETION: "有效语境中的自然结束,不是 interruption yield。", + OUT_OF_SCOPE_WEARER_BACKCHANNEL_OFFSET: "该 wearer 发声只是 backchannel;其 onset 已作为 ENTRY 困难负例审查,尾部 offset 不再重复计为 INT。", OUT_OF_SCOPE_WITHIN_TURN_CONTINUATION: "这是同一 wearer turn 内的暂停或继续,不是新的 floor event。", OUT_OF_SCOPE_POST_COMPLETION_ACTIVITY: "Other 在 wearer 已结束后才开始,不能构成导致停止的 takeover。", OUT_OF_SCOPE_NON_EVENT: "数据有效且可解释,但该边界不属于目标行为。", diff --git a/floor_event_review_web/index.html b/floor_event_review_web/index.html index 85b353a..45aad7e 100644 --- a/floor_event_review_web/index.html +++ b/floor_event_review_web/index.html @@ -88,7 +88,7 @@ - +
仅在原 annotation 明显错误时校正边界
diff --git a/manuscript/main/main.tex b/manuscript/main/main.tex index d63c2ac..9eded13 100644 --- a/manuscript/main/main.tex +++ b/manuscript/main/main.tex @@ -609,6 +609,9 @@ admit nor reject a candidate and do not establish takeover. The LLM and human annotator answer whether the wearer stopped because a non-wearer genuinely took over the floor. Natural completion, post-completion activity, and within-turn pauses are therefore resolved semantically rather than pre-filtered. +When a wearer activity run is itself only a backchannel within a non-wearer's +retained turn, its onset supplies the false-entry opportunity; its trailing +offset is excluded rather than counted again as a false-yield opportunity. \subsection{False-positive opportunity mining} diff --git a/reports/README.md b/reports/README.md index 95b6f4f..877cc76 100644 --- a/reports/README.md +++ b/reports/README.md @@ -7,6 +7,10 @@ Current artifact: - [`egospeak_easycom_feature_audit.json`](egospeak_easycom_feature_audit.json): released EgoSpeak EasyCom feature-layout audit. +- [`easycom_event_ai_prelabel_stats_v15/`](easycom_event_ai_prelabel_stats_v15/): + descriptive DeepSeek v15 pre-label counts for EasyCom Sessions 1--4. These + rows are all unreviewed and must not replace the manuscript's + human-adjudicated annotation statistics. When adding a report, record the generating script, command, source dataset version and timestamp in the payload or an adjacent README. Derived timing or diff --git a/reports/easycom_event_ai_prelabel_stats_v15/README.md b/reports/easycom_event_ai_prelabel_stats_v15/README.md new file mode 100644 index 0000000..3cf5d4c --- /dev/null +++ b/reports/easycom_event_ai_prelabel_stats_v15/README.md @@ -0,0 +1,32 @@ +# EasyCom event AI pre-label audit + +> These are DeepSeek pre-labels awaiting mandatory human adjudication, not ground truth and not final manuscript annotation counts. + +Inputs contain 2,128 unique candidates from Session_1, Session_2, Session_3, Session_4. Protocol: `easycom-wearer-entry-int-candidates-v8`; prompt: `easycom-wearer-boundary-semantic-prelabel-v15`. + +## Cohort overview + +| Cohort | Candidates | Positive | Hard negative | Exclude | Prelabel scoring-eligible | Two-pass semantic/full agreement | Human reviewed | +|---|---:|---:|---:|---:|---:|---:|---:| +| Session_1 | 496 | 116 | 182 | 198 | 298 (60.08%) | 458/496 (92.34%) / 453/496 (91.33%) | 0 | +| Session_2 | 490 | 71 | 252 | 167 | 323 (65.92%) | 454/490 (92.65%) / 452/490 (92.24%) | 0 | +| Session_3 | 545 | 117 | 184 | 244 | 301 (55.23%) | 489/545 (89.72%) / 486/545 (89.17%) | 0 | +| Session_4 | 597 | 84 | 381 | 132 | 465 (77.89%) | 575/597 (96.31%) / 571/597 (95.64%) | 0 | +| Test pooled (Sessions 1-3) | 1531 | 304 | 618 | 609 | 922 (60.22%) | 1401/1531 (91.51%) / 1391/1531 (90.86%) | 0 | +| Session 4 audit (train split) | 597 | 84 | 381 | 132 | 465 (77.89%) | 575/597 (96.31%) / 571/597 (95.64%) | 0 | +| All supplied (descriptive only) | 2128 | 388 | 999 | 741 | 1387 (65.18%) | 1976/2128 (92.86%) / 1962/2128 (92.20%) | 0 | + +The official EasyCom test sessions (1--3) are pooled only for descriptive reporting; these AI pre-labels must not be used to tune thresholds, taxonomy, prompts, or model choices. Session 4 is shown separately as a train-split audit and is not the complete Sessions 4--12 training corpus. + +Detailed counts are in `taxonomy_counts.csv` and `candidate_source_counts.csv`. `manuscript_ai_prelabel_table.tex` mirrors the manuscript row order but is deliberately labeled as an AI audit; retain the manuscript TODOs until human adjudication is frozen. + +## Reproduce + +```bash +python3 summarize_easycom_event_prelabels.py \ + --input reports/easycom_entry_int_session1_v14/easycom_eot_int_deepseek_official.jsonl \ + --input reports/easycom_entry_int_session2_v14/easycom_eot_int_deepseek_official.jsonl \ + --input reports/easycom_entry_int_session3_v14/easycom_eot_int_deepseek_official.jsonl \ + --input reports/easycom_entry_int_session4_v14/easycom_eot_int_deepseek_official.jsonl \ + --output-dir reports/easycom_event_ai_prelabel_stats_v15 +``` diff --git a/reports/easycom_event_ai_prelabel_stats_v15/candidate_source_counts.csv b/reports/easycom_event_ai_prelabel_stats_v15/candidate_source_counts.csv new file mode 100644 index 0000000..e59aa54 --- /dev/null +++ b/reports/easycom_event_ai_prelabel_stats_v15/candidate_source_counts.csv @@ -0,0 +1,43 @@ +cohort,candidate_source,count,percent_of_all_candidates +Session_1,NON_TAKEOVER_OVERLAP,21,4.23 +Session_1,OTHER_BACKCHANNEL,20,4.03 +Session_1,OTHER_TO_OTHER,56,11.29 +Session_1,SAME_SPEAKER_PAUSE,60,12.1 +Session_1,WEARER_OFFSET,170,34.27 +Session_1,WEARER_ONSET,169,34.07 +Session_2,NON_TAKEOVER_OVERLAP,14,2.86 +Session_2,OTHER_BACKCHANNEL,15,3.06 +Session_2,OTHER_TO_OTHER,123,25.1 +Session_2,SAME_SPEAKER_PAUSE,84,17.14 +Session_2,WEARER_OFFSET,127,25.92 +Session_2,WEARER_ONSET,127,25.92 +Session_3,NON_TAKEOVER_OVERLAP,22,4.04 +Session_3,OTHER_BACKCHANNEL,19,3.49 +Session_3,OTHER_TO_OTHER,49,8.99 +Session_3,SAME_SPEAKER_PAUSE,63,11.56 +Session_3,WEARER_OFFSET,196,35.96 +Session_3,WEARER_ONSET,196,35.96 +Session_4,NON_TAKEOVER_OVERLAP,12,2.01 +Session_4,OTHER_BACKCHANNEL,3,0.5 +Session_4,OTHER_TO_OTHER,255,42.71 +Session_4,SAME_SPEAKER_PAUSE,105,17.59 +Session_4,WEARER_OFFSET,111,18.59 +Session_4,WEARER_ONSET,111,18.59 +Test pooled (Sessions 1-3),NON_TAKEOVER_OVERLAP,57,3.72 +Test pooled (Sessions 1-3),OTHER_BACKCHANNEL,54,3.53 +Test pooled (Sessions 1-3),OTHER_TO_OTHER,228,14.89 +Test pooled (Sessions 1-3),SAME_SPEAKER_PAUSE,207,13.52 +Test pooled (Sessions 1-3),WEARER_OFFSET,493,32.2 +Test pooled (Sessions 1-3),WEARER_ONSET,492,32.14 +Session 4 audit (train split),NON_TAKEOVER_OVERLAP,12,2.01 +Session 4 audit (train split),OTHER_BACKCHANNEL,3,0.5 +Session 4 audit (train split),OTHER_TO_OTHER,255,42.71 +Session 4 audit (train split),SAME_SPEAKER_PAUSE,105,17.59 +Session 4 audit (train split),WEARER_OFFSET,111,18.59 +Session 4 audit (train split),WEARER_ONSET,111,18.59 +All supplied (descriptive only),NON_TAKEOVER_OVERLAP,69,3.24 +All supplied (descriptive only),OTHER_BACKCHANNEL,57,2.68 +All supplied (descriptive only),OTHER_TO_OTHER,483,22.7 +All supplied (descriptive only),SAME_SPEAKER_PAUSE,312,14.66 +All supplied (descriptive only),WEARER_OFFSET,604,28.38 +All supplied (descriptive only),WEARER_ONSET,603,28.34 diff --git a/reports/easycom_event_ai_prelabel_stats_v15/cohort_summary.csv b/reports/easycom_event_ai_prelabel_stats_v15/cohort_summary.csv new file mode 100644 index 0000000..6533bdc --- /dev/null +++ b/reports/easycom_event_ai_prelabel_stats_v15/cohort_summary.csv @@ -0,0 +1,8 @@ +cohort,sessions,num_candidates,num_entry_family,num_int_family,num_positive,num_hard_negative,num_exclude,num_scoring_eligible_prelabels,scoring_eligible_prelabel_percent,mean_ai_confidence,median_ai_confidence,num_rows_with_two_passes,num_two_pass_semantic_agreement,two_pass_semantic_agreement_percent,num_two_pass_full_decision_agreement,two_pass_full_decision_agreement_percent,num_human_reviewed +Session_1,['Session_1'],496,285,211,116,182,198,298,60.08,0.7012,0.7,496,458,92.34,453,91.33,0 +Session_2,['Session_2'],490,334,156,71,252,167,323,65.92,0.7143,0.7,490,454,92.65,452,92.24,0 +Session_3,['Session_3'],545,308,237,117,184,244,301,55.23,0.7256,0.75,545,489,89.72,486,89.17,0 +Session_4,['Session_4'],597,471,126,84,381,132,465,77.89,0.7348,0.75,597,575,96.31,571,95.64,0 +Test pooled (Sessions 1-3),"['Session_1', 'Session_2', 'Session_3']",1531,927,604,304,618,609,922,60.22,0.7141,0.7,1531,1401,91.51,1391,90.86,0 +Session 4 audit (train split),['Session_4'],597,471,126,84,381,132,465,77.89,0.7348,0.75,597,575,96.31,571,95.64,0 +All supplied (descriptive only),"['Session_1', 'Session_2', 'Session_3', 'Session_4']",2128,1398,730,388,999,741,1387,65.18,0.7199,0.7,2128,1976,92.86,1962,92.2,0 diff --git a/reports/easycom_event_ai_prelabel_stats_v15/manuscript_ai_prelabel_table.tex b/reports/easycom_event_ai_prelabel_stats_v15/manuscript_ai_prelabel_table.tex new file mode 100644 index 0000000..912595b --- /dev/null +++ b/reports/easycom_event_ai_prelabel_stats_v15/manuscript_ai_prelabel_table.tex @@ -0,0 +1,21 @@ +% AI pre-label audit only; do not substitute for the human-adjudicated manuscript table. +\begin{tabular}{lrr} +\hline +AI pre-label outcome & Session 4 audit & Test Sessions 1--3 \\ +\hline +Positive: wearer entry & 75 & 265 \\ +HN: same-speaker pause & 104 & 189 \\ +HN: other-to-other transition & 251 & 228 \\ +HN: wearer backchannel & 11 & 95 \\ +Positive: INT-yield & 9 & 39 \\ +HN: other backchannel & 9 & 71 \\ +HN: non-takeover overlap & 6 & 23 \\ +HN: wearer holds floor & 0 & 12 \\ +HN: other & 0 & 0 \\ +Excluded: valid but out of scope & 110 & 479 \\ +Excluded: insufficient evidence & 22 & 130 \\ +Excluded: data quality & 0 & 0 \\ +Scoring-eligible total & 465 & 922 \\ +All candidates & 597 & 1531 \\ +\hline +\end{tabular} diff --git a/reports/easycom_event_ai_prelabel_stats_v15/summary.json b/reports/easycom_event_ai_prelabel_stats_v15/summary.json new file mode 100644 index 0000000..b0cf0c1 --- /dev/null +++ b/reports/easycom_event_ai_prelabel_stats_v15/summary.json @@ -0,0 +1,191 @@ +{ + "artifact_type": "AI_PRELABEL_DESCRIPTIVE_STATISTICS_NOT_GROUND_TRUTH", + "warning": "All supplied rows require human adjudication. Do not place these counts in the manuscript table labeled after human adjudication.", + "inputs": [ + "reports/easycom_entry_int_session1_v14/easycom_eot_int_deepseek_official.jsonl", + "reports/easycom_entry_int_session2_v14/easycom_eot_int_deepseek_official.jsonl", + "reports/easycom_entry_int_session3_v14/easycom_eot_int_deepseek_official.jsonl", + "reports/easycom_entry_int_session4_v14/easycom_eot_int_deepseek_official.jsonl" + ], + "metadata": { + "protocol_versions": [ + "easycom-wearer-entry-int-candidates-v8" + ], + "schema_versions": [ + "6" + ], + "prompt_versions": [ + "easycom-wearer-boundary-semantic-prelabel-v15" + ], + "models": [ + "deepseek-flash" + ], + "passes": [ + "2" + ], + "human_statuses": { + "UNREVIEWED": 2128 + } + }, + "cohorts": [ + { + "cohort": "Session_1", + "sessions": [ + "Session_1" + ], + "num_candidates": 496, + "num_entry_family": 285, + "num_int_family": 211, + "num_positive": 116, + "num_hard_negative": 182, + "num_exclude": 198, + "num_scoring_eligible_prelabels": 298, + "scoring_eligible_prelabel_percent": 60.08, + "mean_ai_confidence": 0.7012, + "median_ai_confidence": 0.7, + "num_rows_with_two_passes": 496, + "num_two_pass_semantic_agreement": 458, + "two_pass_semantic_agreement_percent": 92.34, + "num_two_pass_full_decision_agreement": 453, + "two_pass_full_decision_agreement_percent": 91.33, + "num_human_reviewed": 0 + }, + { + "cohort": "Session_2", + "sessions": [ + "Session_2" + ], + "num_candidates": 490, + "num_entry_family": 334, + "num_int_family": 156, + "num_positive": 71, + "num_hard_negative": 252, + "num_exclude": 167, + "num_scoring_eligible_prelabels": 323, + "scoring_eligible_prelabel_percent": 65.92, + "mean_ai_confidence": 0.7143, + "median_ai_confidence": 0.7, + "num_rows_with_two_passes": 490, + "num_two_pass_semantic_agreement": 454, + "two_pass_semantic_agreement_percent": 92.65, + "num_two_pass_full_decision_agreement": 452, + "two_pass_full_decision_agreement_percent": 92.24, + "num_human_reviewed": 0 + }, + { + "cohort": "Session_3", + "sessions": [ + "Session_3" + ], + "num_candidates": 545, + "num_entry_family": 308, + "num_int_family": 237, + "num_positive": 117, + "num_hard_negative": 184, + "num_exclude": 244, + "num_scoring_eligible_prelabels": 301, + "scoring_eligible_prelabel_percent": 55.23, + "mean_ai_confidence": 0.7256, + "median_ai_confidence": 0.75, + "num_rows_with_two_passes": 545, + "num_two_pass_semantic_agreement": 489, + "two_pass_semantic_agreement_percent": 89.72, + "num_two_pass_full_decision_agreement": 486, + "two_pass_full_decision_agreement_percent": 89.17, + "num_human_reviewed": 0 + }, + { + "cohort": "Session_4", + "sessions": [ + "Session_4" + ], + "num_candidates": 597, + "num_entry_family": 471, + "num_int_family": 126, + "num_positive": 84, + "num_hard_negative": 381, + "num_exclude": 132, + "num_scoring_eligible_prelabels": 465, + "scoring_eligible_prelabel_percent": 77.89, + "mean_ai_confidence": 0.7348, + "median_ai_confidence": 0.75, + "num_rows_with_two_passes": 597, + "num_two_pass_semantic_agreement": 575, + "two_pass_semantic_agreement_percent": 96.31, + "num_two_pass_full_decision_agreement": 571, + "two_pass_full_decision_agreement_percent": 95.64, + "num_human_reviewed": 0 + }, + { + "cohort": "Test pooled (Sessions 1-3)", + "sessions": [ + "Session_1", + "Session_2", + "Session_3" + ], + "num_candidates": 1531, + "num_entry_family": 927, + "num_int_family": 604, + "num_positive": 304, + "num_hard_negative": 618, + "num_exclude": 609, + "num_scoring_eligible_prelabels": 922, + "scoring_eligible_prelabel_percent": 60.22, + "mean_ai_confidence": 0.7141, + "median_ai_confidence": 0.7, + "num_rows_with_two_passes": 1531, + "num_two_pass_semantic_agreement": 1401, + "two_pass_semantic_agreement_percent": 91.51, + "num_two_pass_full_decision_agreement": 1391, + "two_pass_full_decision_agreement_percent": 90.86, + "num_human_reviewed": 0 + }, + { + "cohort": "Session 4 audit (train split)", + "sessions": [ + "Session_4" + ], + "num_candidates": 597, + "num_entry_family": 471, + "num_int_family": 126, + "num_positive": 84, + "num_hard_negative": 381, + "num_exclude": 132, + "num_scoring_eligible_prelabels": 465, + "scoring_eligible_prelabel_percent": 77.89, + "mean_ai_confidence": 0.7348, + "median_ai_confidence": 0.75, + "num_rows_with_two_passes": 597, + "num_two_pass_semantic_agreement": 575, + "two_pass_semantic_agreement_percent": 96.31, + "num_two_pass_full_decision_agreement": 571, + "two_pass_full_decision_agreement_percent": 95.64, + "num_human_reviewed": 0 + }, + { + "cohort": "All supplied (descriptive only)", + "sessions": [ + "Session_1", + "Session_2", + "Session_3", + "Session_4" + ], + "num_candidates": 2128, + "num_entry_family": 1398, + "num_int_family": 730, + "num_positive": 388, + "num_hard_negative": 999, + "num_exclude": 741, + "num_scoring_eligible_prelabels": 1387, + "scoring_eligible_prelabel_percent": 65.18, + "mean_ai_confidence": 0.7199, + "median_ai_confidence": 0.7, + "num_rows_with_two_passes": 2128, + "num_two_pass_semantic_agreement": 1976, + "two_pass_semantic_agreement_percent": 92.86, + "num_two_pass_full_decision_agreement": 1962, + "two_pass_full_decision_agreement_percent": 92.2, + "num_human_reviewed": 0 + } + ] +} diff --git a/reports/easycom_event_ai_prelabel_stats_v15/taxonomy_counts.csv b/reports/easycom_event_ai_prelabel_stats_v15/taxonomy_counts.csv new file mode 100644 index 0000000..8f49c34 --- /dev/null +++ b/reports/easycom_event_ai_prelabel_stats_v15/taxonomy_counts.csv @@ -0,0 +1,125 @@ +cohort,dimension,label,count,percent_of_all_candidates +Session_1,verdict,EXCLUDE,198,39.92 +Session_1,verdict,HARD_NEGATIVE,182,36.69 +Session_1,verdict,POSITIVE,116,23.39 +Session_1,positive_event,INT_YIELD,20,4.03 +Session_1,positive_event,WEARER_ENTRY,96,19.35 +Session_1,hard_negative_subtype,ENTRY_OTHER_MIDTURN_PAUSE,58,11.69 +Session_1,hard_negative_subtype,ENTRY_OTHER_TO_OTHER,56,11.29 +Session_1,hard_negative_subtype,ENTRY_WEARER_BACKCHANNEL,28,5.65 +Session_1,hard_negative_subtype,INT_NON_TAKEOVER_OVERLAP,10,2.02 +Session_1,hard_negative_subtype,INT_OTHER_BACKCHANNEL,29,5.85 +Session_1,hard_negative_subtype,INT_WEARER_HOLDS_FLOOR,1,0.2 +Session_1,exclude_reason,INSUFFICIENT_EVIDENCE,38,7.66 +Session_1,exclude_reason,OUT_OF_SCOPE_NON_EVENT,8,1.61 +Session_1,exclude_reason,OUT_OF_SCOPE_NORMAL_COMPLETION,104,20.97 +Session_1,exclude_reason,OUT_OF_SCOPE_POST_COMPLETION_ACTIVITY,21,4.23 +Session_1,exclude_reason,OUT_OF_SCOPE_WITHIN_TURN_CONTINUATION,27,5.44 +Session_1,exclude_group,INSUFFICIENT_EVIDENCE,38,7.66 +Session_1,exclude_group,OUT_OF_SCOPE,160,32.26 +Session_2,verdict,EXCLUDE,167,34.08 +Session_2,verdict,HARD_NEGATIVE,252,51.43 +Session_2,verdict,POSITIVE,71,14.49 +Session_2,positive_event,INT_YIELD,5,1.02 +Session_2,positive_event,WEARER_ENTRY,66,13.47 +Session_2,hard_negative_subtype,ENTRY_OTHER_MIDTURN_PAUSE,76,15.51 +Session_2,hard_negative_subtype,ENTRY_OTHER_TO_OTHER,123,25.1 +Session_2,hard_negative_subtype,ENTRY_WEARER_BACKCHANNEL,27,5.51 +Session_2,hard_negative_subtype,INT_NON_TAKEOVER_OVERLAP,7,1.43 +Session_2,hard_negative_subtype,INT_OTHER_BACKCHANNEL,15,3.06 +Session_2,hard_negative_subtype,INT_WEARER_HOLDS_FLOOR,4,0.82 +Session_2,exclude_reason,INSUFFICIENT_EVIDENCE,36,7.35 +Session_2,exclude_reason,OUT_OF_SCOPE_NON_EVENT,3,0.61 +Session_2,exclude_reason,OUT_OF_SCOPE_NORMAL_COMPLETION,97,19.8 +Session_2,exclude_reason,OUT_OF_SCOPE_POST_COMPLETION_ACTIVITY,4,0.82 +Session_2,exclude_reason,OUT_OF_SCOPE_WITHIN_TURN_CONTINUATION,27,5.51 +Session_2,exclude_group,INSUFFICIENT_EVIDENCE,36,7.35 +Session_2,exclude_group,OUT_OF_SCOPE,131,26.73 +Session_3,verdict,EXCLUDE,244,44.77 +Session_3,verdict,HARD_NEGATIVE,184,33.76 +Session_3,verdict,POSITIVE,117,21.47 +Session_3,positive_event,INT_YIELD,14,2.57 +Session_3,positive_event,WEARER_ENTRY,103,18.9 +Session_3,hard_negative_subtype,ENTRY_OTHER_MIDTURN_PAUSE,55,10.09 +Session_3,hard_negative_subtype,ENTRY_OTHER_TO_OTHER,49,8.99 +Session_3,hard_negative_subtype,ENTRY_WEARER_BACKCHANNEL,40,7.34 +Session_3,hard_negative_subtype,INT_NON_TAKEOVER_OVERLAP,6,1.1 +Session_3,hard_negative_subtype,INT_OTHER_BACKCHANNEL,27,4.95 +Session_3,hard_negative_subtype,INT_WEARER_HOLDS_FLOOR,7,1.28 +Session_3,exclude_reason,INSUFFICIENT_EVIDENCE,56,10.28 +Session_3,exclude_reason,OUT_OF_SCOPE_NON_EVENT,23,4.22 +Session_3,exclude_reason,OUT_OF_SCOPE_NORMAL_COMPLETION,99,18.17 +Session_3,exclude_reason,OUT_OF_SCOPE_POST_COMPLETION_ACTIVITY,25,4.59 +Session_3,exclude_reason,OUT_OF_SCOPE_WITHIN_TURN_CONTINUATION,41,7.52 +Session_3,exclude_group,INSUFFICIENT_EVIDENCE,56,10.28 +Session_3,exclude_group,OUT_OF_SCOPE,188,34.5 +Session_4,verdict,EXCLUDE,132,22.11 +Session_4,verdict,HARD_NEGATIVE,381,63.82 +Session_4,verdict,POSITIVE,84,14.07 +Session_4,positive_event,INT_YIELD,9,1.51 +Session_4,positive_event,WEARER_ENTRY,75,12.56 +Session_4,hard_negative_subtype,ENTRY_OTHER_MIDTURN_PAUSE,104,17.42 +Session_4,hard_negative_subtype,ENTRY_OTHER_TO_OTHER,251,42.04 +Session_4,hard_negative_subtype,ENTRY_WEARER_BACKCHANNEL,11,1.84 +Session_4,hard_negative_subtype,INT_NON_TAKEOVER_OVERLAP,6,1.01 +Session_4,hard_negative_subtype,INT_OTHER_BACKCHANNEL,9,1.51 +Session_4,exclude_reason,INSUFFICIENT_EVIDENCE,22,3.69 +Session_4,exclude_reason,OUT_OF_SCOPE_NON_EVENT,4,0.67 +Session_4,exclude_reason,OUT_OF_SCOPE_NORMAL_COMPLETION,90,15.08 +Session_4,exclude_reason,OUT_OF_SCOPE_POST_COMPLETION_ACTIVITY,3,0.5 +Session_4,exclude_reason,OUT_OF_SCOPE_WITHIN_TURN_CONTINUATION,13,2.18 +Session_4,exclude_group,INSUFFICIENT_EVIDENCE,22,3.69 +Session_4,exclude_group,OUT_OF_SCOPE,110,18.43 +Test pooled (Sessions 1-3),verdict,EXCLUDE,609,39.78 +Test pooled (Sessions 1-3),verdict,HARD_NEGATIVE,618,40.37 +Test pooled (Sessions 1-3),verdict,POSITIVE,304,19.86 +Test pooled (Sessions 1-3),positive_event,INT_YIELD,39,2.55 +Test pooled (Sessions 1-3),positive_event,WEARER_ENTRY,265,17.31 +Test pooled (Sessions 1-3),hard_negative_subtype,ENTRY_OTHER_MIDTURN_PAUSE,189,12.34 +Test pooled (Sessions 1-3),hard_negative_subtype,ENTRY_OTHER_TO_OTHER,228,14.89 +Test pooled (Sessions 1-3),hard_negative_subtype,ENTRY_WEARER_BACKCHANNEL,95,6.21 +Test pooled (Sessions 1-3),hard_negative_subtype,INT_NON_TAKEOVER_OVERLAP,23,1.5 +Test pooled (Sessions 1-3),hard_negative_subtype,INT_OTHER_BACKCHANNEL,71,4.64 +Test pooled (Sessions 1-3),hard_negative_subtype,INT_WEARER_HOLDS_FLOOR,12,0.78 +Test pooled (Sessions 1-3),exclude_reason,INSUFFICIENT_EVIDENCE,130,8.49 +Test pooled (Sessions 1-3),exclude_reason,OUT_OF_SCOPE_NON_EVENT,34,2.22 +Test pooled (Sessions 1-3),exclude_reason,OUT_OF_SCOPE_NORMAL_COMPLETION,300,19.6 +Test pooled (Sessions 1-3),exclude_reason,OUT_OF_SCOPE_POST_COMPLETION_ACTIVITY,50,3.27 +Test pooled (Sessions 1-3),exclude_reason,OUT_OF_SCOPE_WITHIN_TURN_CONTINUATION,95,6.21 +Test pooled (Sessions 1-3),exclude_group,INSUFFICIENT_EVIDENCE,130,8.49 +Test pooled (Sessions 1-3),exclude_group,OUT_OF_SCOPE,479,31.29 +Session 4 audit (train split),verdict,EXCLUDE,132,22.11 +Session 4 audit (train split),verdict,HARD_NEGATIVE,381,63.82 +Session 4 audit (train split),verdict,POSITIVE,84,14.07 +Session 4 audit (train split),positive_event,INT_YIELD,9,1.51 +Session 4 audit (train split),positive_event,WEARER_ENTRY,75,12.56 +Session 4 audit (train split),hard_negative_subtype,ENTRY_OTHER_MIDTURN_PAUSE,104,17.42 +Session 4 audit (train split),hard_negative_subtype,ENTRY_OTHER_TO_OTHER,251,42.04 +Session 4 audit (train split),hard_negative_subtype,ENTRY_WEARER_BACKCHANNEL,11,1.84 +Session 4 audit (train split),hard_negative_subtype,INT_NON_TAKEOVER_OVERLAP,6,1.01 +Session 4 audit (train split),hard_negative_subtype,INT_OTHER_BACKCHANNEL,9,1.51 +Session 4 audit (train split),exclude_reason,INSUFFICIENT_EVIDENCE,22,3.69 +Session 4 audit (train split),exclude_reason,OUT_OF_SCOPE_NON_EVENT,4,0.67 +Session 4 audit (train split),exclude_reason,OUT_OF_SCOPE_NORMAL_COMPLETION,90,15.08 +Session 4 audit (train split),exclude_reason,OUT_OF_SCOPE_POST_COMPLETION_ACTIVITY,3,0.5 +Session 4 audit (train split),exclude_reason,OUT_OF_SCOPE_WITHIN_TURN_CONTINUATION,13,2.18 +Session 4 audit (train split),exclude_group,INSUFFICIENT_EVIDENCE,22,3.69 +Session 4 audit (train split),exclude_group,OUT_OF_SCOPE,110,18.43 +All supplied (descriptive only),verdict,EXCLUDE,741,34.82 +All supplied (descriptive only),verdict,HARD_NEGATIVE,999,46.95 +All supplied (descriptive only),verdict,POSITIVE,388,18.23 +All supplied (descriptive only),positive_event,INT_YIELD,48,2.26 +All supplied (descriptive only),positive_event,WEARER_ENTRY,340,15.98 +All supplied (descriptive only),hard_negative_subtype,ENTRY_OTHER_MIDTURN_PAUSE,293,13.77 +All supplied (descriptive only),hard_negative_subtype,ENTRY_OTHER_TO_OTHER,479,22.51 +All supplied (descriptive only),hard_negative_subtype,ENTRY_WEARER_BACKCHANNEL,106,4.98 +All supplied (descriptive only),hard_negative_subtype,INT_NON_TAKEOVER_OVERLAP,29,1.36 +All supplied (descriptive only),hard_negative_subtype,INT_OTHER_BACKCHANNEL,80,3.76 +All supplied (descriptive only),hard_negative_subtype,INT_WEARER_HOLDS_FLOOR,12,0.56 +All supplied (descriptive only),exclude_reason,INSUFFICIENT_EVIDENCE,152,7.14 +All supplied (descriptive only),exclude_reason,OUT_OF_SCOPE_NON_EVENT,38,1.79 +All supplied (descriptive only),exclude_reason,OUT_OF_SCOPE_NORMAL_COMPLETION,390,18.33 +All supplied (descriptive only),exclude_reason,OUT_OF_SCOPE_POST_COMPLETION_ACTIVITY,53,2.49 +All supplied (descriptive only),exclude_reason,OUT_OF_SCOPE_WITHIN_TURN_CONTINUATION,108,5.08 +All supplied (descriptive only),exclude_group,INSUFFICIENT_EVIDENCE,152,7.14 +All supplied (descriptive only),exclude_group,OUT_OF_SCOPE,589,27.68 diff --git a/summarize_easycom_event_prelabels.py b/summarize_easycom_event_prelabels.py new file mode 100644 index 0000000..5eb0530 --- /dev/null +++ b/summarize_easycom_event_prelabels.py @@ -0,0 +1,276 @@ +#!/usr/bin/env python3 +"""Summarize EasyCom AI event pre-labels without treating them as ground truth.""" + +from __future__ import annotations + +import argparse +import csv +import json +import statistics +from collections import Counter +from pathlib import Path +from typing import Any, Iterable + + +MANUSCRIPT_ROWS = [ + ("Positive: wearer entry", "event", "WEARER_ENTRY"), + ("HN: same-speaker pause", "subtype", "ENTRY_OTHER_MIDTURN_PAUSE"), + ("HN: other-to-other transition", "subtype", "ENTRY_OTHER_TO_OTHER"), + ("HN: wearer backchannel", "subtype", "ENTRY_WEARER_BACKCHANNEL"), + ("Positive: INT-yield", "event", "INT_YIELD"), + ("HN: other backchannel", "subtype", "INT_OTHER_BACKCHANNEL"), + ("HN: non-takeover overlap", "subtype", "INT_NON_TAKEOVER_OVERLAP"), + ("HN: wearer holds floor", "subtype", "INT_WEARER_HOLDS_FLOOR"), + ("HN: other", "subtype", "OTHER_HARD_NEGATIVE"), + ("Excluded: valid but out of scope", "exclude_group", "OUT_OF_SCOPE"), + ("Excluded: insufficient evidence", "exclude_group", "INSUFFICIENT_EVIDENCE"), + ("Excluded: data quality", "exclude_group", "DATA_QUALITY"), + ("Scoring-eligible total", "derived", "SCORING_ELIGIBLE"), + ("All candidates", "derived", "ALL"), +] + + +def _read_jsonl(path: Path) -> list[dict[str, Any]]: + rows: list[dict[str, Any]] = [] + with path.open(encoding="utf-8") as handle: + for line_number, line in enumerate(handle, 1): + if not line.strip(): + continue + try: + row = json.loads(line) + except json.JSONDecodeError as error: + raise ValueError(f"Invalid JSON at {path}:{line_number}: {error}") from error + row["_source_file"] = str(path) + rows.append(row) + return rows + + +def _exclude_group(row: dict[str, Any]) -> str | None: + group = row.get("ai_exclude_reason_group") + if group: + return str(group) + reason = row.get("ai_exclude_reason") + if not reason: + return None + if str(reason).startswith("OUT_OF_SCOPE_"): + return "OUT_OF_SCOPE" + if str(reason).startswith("DATA_"): + return "DATA_QUALITY" + return str(reason) + + +def _decision_key(decision: dict[str, Any], *, include_opportunity: bool) -> tuple[Any, ...]: + keys = ["verdict", "event_type", "subtype", "exclude_reason"] + if include_opportunity: + keys.append("selected_opportunity_uid") + return tuple( + decision.get(key) for key in keys + ) + + +def _count(rows: Iterable[dict[str, Any]], dimension: str, value: str) -> int: + rows = list(rows) + if dimension == "event": + return sum(row.get("ai_verdict") == "POSITIVE" and row.get("ai_event_type") == value for row in rows) + if dimension == "subtype": + return sum(row.get("ai_verdict") == "HARD_NEGATIVE" and row.get("ai_subtype") == value for row in rows) + if dimension == "exclude_group": + return sum(row.get("ai_verdict") == "EXCLUDE" and _exclude_group(row) == value for row in rows) + if value == "SCORING_ELIGIBLE": + return sum(row.get("ai_verdict") in {"POSITIVE", "HARD_NEGATIVE"} for row in rows) + if value == "ALL": + return len(rows) + raise ValueError((dimension, value)) + + +def _cohort_summary(name: str, rows: list[dict[str, Any]]) -> dict[str, Any]: + confidences = [float(row["ai_confidence"]) for row in rows if row.get("ai_confidence") is not None] + pass_rows = 0 + semantic_agree = 0 + full_agree = 0 + for row in rows: + decisions = (row.get("llm_adjudication") or {}).get("decisions") or [] + if len(decisions) >= 2: + pass_rows += 1 + semantic_agree += len({_decision_key(decision, include_opportunity=False) for decision in decisions}) == 1 + full_agree += len({_decision_key(decision, include_opportunity=True) for decision in decisions}) == 1 + verdicts = Counter(str(row.get("ai_verdict")) for row in rows) + return { + "cohort": name, + "sessions": sorted({str(row.get("session")) for row in rows}), + "num_candidates": len(rows), + "num_entry_family": sum(row.get("task") == "wearer_entry" for row in rows), + "num_int_family": sum(row.get("task") == "int_yield" for row in rows), + "num_positive": verdicts["POSITIVE"], + "num_hard_negative": verdicts["HARD_NEGATIVE"], + "num_exclude": verdicts["EXCLUDE"], + "num_scoring_eligible_prelabels": verdicts["POSITIVE"] + verdicts["HARD_NEGATIVE"], + "scoring_eligible_prelabel_percent": round( + 100 * (verdicts["POSITIVE"] + verdicts["HARD_NEGATIVE"]) / len(rows), 2 + ) if rows else None, + "mean_ai_confidence": round(statistics.fmean(confidences), 4) if confidences else None, + "median_ai_confidence": round(statistics.median(confidences), 4) if confidences else None, + "num_rows_with_two_passes": pass_rows, + "num_two_pass_semantic_agreement": semantic_agree, + "two_pass_semantic_agreement_percent": round(100 * semantic_agree / pass_rows, 2) if pass_rows else None, + "num_two_pass_full_decision_agreement": full_agree, + "two_pass_full_decision_agreement_percent": round(100 * full_agree / pass_rows, 2) if pass_rows else None, + "num_human_reviewed": sum(row.get("human_status") != "UNREVIEWED" for row in rows), + } + + +def _write_csv(path: Path, fieldnames: list[str], rows: list[dict[str, Any]]) -> None: + with path.open("w", newline="", encoding="utf-8") as handle: + writer = csv.DictWriter(handle, fieldnames=fieldnames) + writer.writeheader() + writer.writerows(rows) + + +def summarize(paths: list[Path], output_dir: Path) -> dict[str, Any]: + all_rows: list[dict[str, Any]] = [] + for path in paths: + all_rows.extend(_read_jsonl(path)) + if not all_rows: + raise ValueError("No rows found") + + duplicate_uids = [uid for uid, count in Counter(row.get("uid") for row in all_rows).items() if count > 1] + if duplicate_uids: + raise ValueError(f"Duplicate uid across inputs: {duplicate_uids[:5]}") + + sessions = sorted({str(row.get("session")) for row in all_rows}) + by_session = {session: [row for row in all_rows if row.get("session") == session] for session in sessions} + test_rows = [row for row in all_rows if row.get("session") in {"Session_1", "Session_2", "Session_3"}] + session4_rows = by_session.get("Session_4", []) + cohorts: dict[str, list[dict[str, Any]]] = {**by_session} + if test_rows: + cohorts["Test pooled (Sessions 1-3)"] = test_rows + if session4_rows: + cohorts["Session 4 audit (train split)"] = session4_rows + cohorts["All supplied (descriptive only)"] = all_rows + + metadata = { + "protocol_versions": sorted({str(row.get("protocol_version")) for row in all_rows}), + "schema_versions": sorted({str(row.get("schema_version")) for row in all_rows}), + "prompt_versions": sorted({str((row.get("llm_adjudication") or {}).get("prompt_version")) for row in all_rows}), + "models": sorted({str((row.get("llm_adjudication") or {}).get("model")) for row in all_rows}), + "passes": sorted({str((row.get("llm_adjudication") or {}).get("passes")) for row in all_rows}), + "human_statuses": dict(sorted(Counter(str(row.get("human_status")) for row in all_rows).items())), + } + for key in ("protocol_versions", "schema_versions", "prompt_versions", "models", "passes"): + if len(metadata[key]) != 1: + raise ValueError(f"Cannot aggregate mixed {key}: {metadata[key]}") + summary = { + "artifact_type": "AI_PRELABEL_DESCRIPTIVE_STATISTICS_NOT_GROUND_TRUTH", + "warning": "All supplied rows require human adjudication. Do not place these counts in the manuscript table labeled after human adjudication.", + "inputs": [str(path) for path in paths], + "metadata": metadata, + "cohorts": [_cohort_summary(name, rows) for name, rows in cohorts.items()], + } + + output_dir.mkdir(parents=True, exist_ok=True) + (output_dir / "summary.json").write_text(json.dumps(summary, indent=2) + "\n", encoding="utf-8") + cohort_rows = summary["cohorts"] + _write_csv(output_dir / "cohort_summary.csv", list(cohort_rows[0]), cohort_rows) + + taxonomy_rows: list[dict[str, Any]] = [] + source_rows: list[dict[str, Any]] = [] + for name, rows in cohorts.items(): + total = len(rows) + dimensions = { + "verdict": Counter(str(row.get("ai_verdict")) for row in rows), + "positive_event": Counter(str(row.get("ai_event_type")) for row in rows if row.get("ai_verdict") == "POSITIVE"), + "hard_negative_subtype": Counter(str(row.get("ai_subtype")) for row in rows if row.get("ai_verdict") == "HARD_NEGATIVE"), + "exclude_reason": Counter(str(row.get("ai_exclude_reason")) for row in rows if row.get("ai_verdict") == "EXCLUDE"), + "exclude_group": Counter(str(_exclude_group(row)) for row in rows if row.get("ai_verdict") == "EXCLUDE"), + } + for dimension, counts in dimensions.items(): + for label, count in sorted(counts.items()): + taxonomy_rows.append({ + "cohort": name, "dimension": dimension, "label": label, + "count": count, "percent_of_all_candidates": round(100 * count / total, 2), + }) + for source, count in sorted(Counter(str(row.get("candidate_source")) for row in rows).items()): + source_rows.append({ + "cohort": name, "candidate_source": source, "count": count, + "percent_of_all_candidates": round(100 * count / total, 2), + }) + _write_csv( + output_dir / "taxonomy_counts.csv", + ["cohort", "dimension", "label", "count", "percent_of_all_candidates"], + taxonomy_rows, + ) + _write_csv( + output_dir / "candidate_source_counts.csv", + ["cohort", "candidate_source", "count", "percent_of_all_candidates"], + source_rows, + ) + + train_label = "Session 4 audit (train split)" + test_label = "Test pooled (Sessions 1-3)" + table_lines = [ + "% AI pre-label audit only; do not substitute for the human-adjudicated manuscript table.", + "\\begin{tabular}{lrr}", + "\\hline", + "AI pre-label outcome & Session 4 audit & Test Sessions 1--3 \\\\", + "\\hline", + ] + for label, dimension, value in MANUSCRIPT_ROWS: + train_count = _count(cohorts.get(train_label, []), dimension, value) + test_count = _count(cohorts.get(test_label, []), dimension, value) + table_lines.append(f"{label} & {train_count} & {test_count} \\\\") + table_lines.extend(["\\hline", "\\end{tabular}", ""]) + (output_dir / "manuscript_ai_prelabel_table.tex").write_text("\n".join(table_lines), encoding="utf-8") + + report_lines = [ + "# EasyCom event AI pre-label audit", + "", + "> These are DeepSeek pre-labels awaiting mandatory human adjudication, not ground truth and not final manuscript annotation counts.", + "", + f"Inputs contain {len(all_rows):,} unique candidates from {', '.join(sessions)}. " + f"Protocol: `{', '.join(metadata['protocol_versions'])}`; prompt: `{', '.join(metadata['prompt_versions'])}`.", + "", + "## Cohort overview", + "", + "| Cohort | Candidates | Positive | Hard negative | Exclude | Prelabel scoring-eligible | Two-pass semantic/full agreement | Human reviewed |", + "|---|---:|---:|---:|---:|---:|---:|---:|", + ] + for item in cohort_rows: + report_lines.append( + f"| {item['cohort']} | {item['num_candidates']} | {item['num_positive']} | " + f"{item['num_hard_negative']} | {item['num_exclude']} | " + f"{item['num_scoring_eligible_prelabels']} ({item['scoring_eligible_prelabel_percent']:.2f}%) | " + f"{item['num_two_pass_semantic_agreement']}/{item['num_rows_with_two_passes']} " + f"({item['two_pass_semantic_agreement_percent']:.2f}%) / " + f"{item['num_two_pass_full_decision_agreement']}/{item['num_rows_with_two_passes']} " + f"({item['two_pass_full_decision_agreement_percent']:.2f}%) | {item['num_human_reviewed']} |" + ) + report_lines.extend([ + "", + "The official EasyCom test sessions (1--3) are pooled only for descriptive reporting; these AI pre-labels must not be used to tune thresholds, taxonomy, prompts, or model choices. Session 4 is shown separately as a train-split audit and is not the complete Sessions 4--12 training corpus.", + "", + "Detailed counts are in `taxonomy_counts.csv` and `candidate_source_counts.csv`. " + "`manuscript_ai_prelabel_table.tex` mirrors the manuscript row order but is deliberately labeled as an AI audit; retain the manuscript TODOs until human adjudication is frozen.", + "", + "## Reproduce", + "", + "```bash", + "python3 summarize_easycom_event_prelabels.py \\", + ]) + for index, path in enumerate(paths): + suffix = " \\" if index < len(paths) - 1 else " \\" + report_lines.append(f" --input {path}{suffix}") + report_lines.extend([f" --output-dir {output_dir}", "```", ""]) + (output_dir / "README.md").write_text("\n".join(report_lines), encoding="utf-8") + return summary + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--input", action="append", type=Path, required=True) + parser.add_argument("--output-dir", type=Path, required=True) + args = parser.parse_args() + summarize(args.input, args.output_dir) + + +if __name__ == "__main__": + main() diff --git a/test_adjudicate_easycom_eot_int_candidates.py b/test_adjudicate_easycom_eot_int_candidates.py index 4130233..ad59863 100644 --- a/test_adjudicate_easycom_eot_int_candidates.py +++ b/test_adjudicate_easycom_eot_int_candidates.py @@ -60,7 +60,7 @@ class EntryINTAdjudicationTests(unittest.TestCase): self.assertIn("Yes.", prompt) def test_system_prompt_treats_laughter_as_nonlexical_floor_feedback(self) -> None: - self.assertEqual(PROMPT_VERSION, "easycom-wearer-boundary-semantic-prelabel-v15") + self.assertEqual(PROMPT_VERSION, "easycom-wearer-boundary-semantic-prelabel-v16") self.assertIn("opportunity_options", 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) @@ -76,6 +76,9 @@ class EntryINTAdjudicationTests(unittest.TestCase): self.assertIn("does not answer a question, accept or reject a proposal", SYSTEM_PROMPT) self.assertIn('the wearer answers "Nope." at 54.15 s', SYSTEM_PROMPT) self.assertIn("participant 3 later speaks again", SYSTEM_PROMPT) + self.assertIn("whether the wearer activity run ending there ever held the floor", SYSTEM_PROMPT) + self.assertIn("OUT_OF_SCOPE_WEARER_BACKCHANNEL_OFFSET", SYSTEM_PROMPT) + self.assertIn("do not count its trailing offset again", 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("normal wearer completion", SYSTEM_PROMPT) @@ -182,6 +185,9 @@ class EntryINTAdjudicationTests(unittest.TestCase): self.assertEqual( exclude_reason_group("OUT_OF_SCOPE_NORMAL_COMPLETION"), "OUT_OF_SCOPE" ) + self.assertEqual( + exclude_reason_group("OUT_OF_SCOPE_WEARER_BACKCHANNEL_OFFSET"), "OUT_OF_SCOPE" + ) self.assertEqual( exclude_reason_group("INSUFFICIENT_EVIDENCE"), "INSUFFICIENT_EVIDENCE" ) diff --git a/test_summarize_easycom_event_prelabels.py b/test_summarize_easycom_event_prelabels.py new file mode 100644 index 0000000..d51abcf --- /dev/null +++ b/test_summarize_easycom_event_prelabels.py @@ -0,0 +1,52 @@ +"""Tests for descriptive EasyCom AI pre-label aggregation.""" + +from __future__ import annotations + +import json +import tempfile +import unittest +from pathlib import Path + +from summarize_easycom_event_prelabels import summarize + + +class EventPrelabelSummaryTests(unittest.TestCase): + def test_summary_separates_ai_prelabels_and_two_pass_agreement(self) -> None: + base = { + "protocol_version": "p1", "schema_version": 6, + "candidate_source": "WEARER_ONSET", "task": "wearer_entry", + "human_status": "UNREVIEWED", "ai_confidence": 0.8, + "llm_adjudication": {"prompt_version": "v1", "model": "m", "passes": 2}, + } + rows = [ + {**base, "uid": "u1", "session": "Session_1", "ai_verdict": "POSITIVE", + "ai_event_type": "WEARER_ENTRY", "ai_subtype": None, + "llm_adjudication": {**base["llm_adjudication"], "decisions": [ + {"verdict": "POSITIVE", "event_type": "WEARER_ENTRY"}, + {"verdict": "POSITIVE", "event_type": "WEARER_ENTRY"}, + ]}}, + {**base, "uid": "u2", "session": "Session_4", "task": "int_yield", + "candidate_source": "WEARER_OFFSET", "ai_verdict": "EXCLUDE", + "ai_event_type": None, "ai_subtype": None, + "ai_exclude_reason": "OUT_OF_SCOPE_NORMAL_COMPLETION", + "ai_exclude_reason_group": "OUT_OF_SCOPE", + "llm_adjudication": {**base["llm_adjudication"], "decisions": [ + {"verdict": "EXCLUDE", "exclude_reason": "OUT_OF_SCOPE_NORMAL_COMPLETION"}, + {"verdict": "EXCLUDE", "exclude_reason": "INSUFFICIENT_EVIDENCE"}, + ]}}, + ] + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + source = root / "input.jsonl" + source.write_text("".join(json.dumps(row) + "\n" for row in rows)) + summary = summarize([source], root / "out") + cohorts = {item["cohort"]: item for item in summary["cohorts"]} + self.assertEqual(cohorts["Test pooled (Sessions 1-3)"]["num_positive"], 1) + self.assertEqual(cohorts["Session 4 audit (train split)"]["num_exclude"], 1) + self.assertEqual(cohorts["All supplied (descriptive only)"]["num_two_pass_semantic_agreement"], 1) + self.assertEqual(cohorts["All supplied (descriptive only)"]["num_two_pass_full_decision_agreement"], 1) + self.assertIn("AI pre-label audit only", (root / "out/manuscript_ai_prelabel_table.tex").read_text()) + + +if __name__ == "__main__": + unittest.main()