Files
UmbraAudio/tests/test_speech_evidence.py
2026-10-18 19:01:04 -07:00

377 lines
17 KiB
Python

# Copyright 2026 Umbra Tools (umbratools.com)
# SPDX-License-Identifier: Apache-2.0
"""Tests for the mechanical speech-evidence gate.
The fixtures are the real defect shapes found in the Last Dwarf chapter 6 and chapter 9
reviews, including the two that the previous regex matcher could not parse at all.
"""
from __future__ import annotations
from umbraaudio.analysis.speech_evidence import (
candidate_speakers,
extract_tags,
nearest_tag,
support,
)
CAST = {
"hallr": {"primary_name": "Hallr", "gender": "male", "aliases": []},
"scum leader": {"primary_name": "Scum leader", "gender": "male", "aliases": ["scum leader"]},
"the leader": {"primary_name": "The Leader", "gender": "male", "aliases": []},
"scum country boy": {
"primary_name": "Scum country boy",
"gender": "male",
"aliases": [],
},
"scum guy1": {"primary_name": "Scum guy1", "gender": "male", "aliases": []},
"sheyla": {"primary_name": "Sheyla", "gender": "female", "aliases": []},
"neriah": {"primary_name": "Neriah", "gender": "female", "aliases": []},
"the officer": {"primary_name": "The Officer", "gender": "male", "aliases": []},
"royal guard": {"primary_name": "Royal Guard", "gender": "male", "aliases": []},
"feliu": {"primary_name": "Feliu", "gender": "male", "aliases": []},
"officer": {"primary_name": "Officer", "gender": "male", "aliases": []},
"the woman": {"primary_name": "The Woman", "gender": "female", "aliases": []},
"the kid": {"primary_name": "The Kid", "gender": "male", "aliases": []},
}
SCENE = ["Hallr", "Scum leader", "Scum country boy", "Scum guy1", "Sheyla"]
class TestExtraction:
def test_role_with_spatial_qualifier_and_addressee(self):
scan = extract_tags("The leader on the right for Hallr says,", CAST)
assert len(scan.tags) == 1
tag = scan.tags[0]
assert tag.kind == "role"
assert tag.addressee == "Hallr"
assert tag.head_noun == "leader"
def test_role_with_addressee_only(self):
tag = extract_tags("The woman says to Tobias,", CAST).tags[0]
assert tag.kind == "role"
assert tag.value == "The woman"
def test_plain_role(self):
tag = extract_tags("The guard says,", CAST).tags[0]
assert tag.kind == "role"
assert tag.head_noun == "guard"
def test_name_tag_resolved(self):
tag = extract_tags("Sheyla says,", CAST).tags[0]
assert (tag.kind, tag.value, tag.resolved) == ("name", "Sheyla", True)
def test_name_tag_unknown_is_unresolved_not_role(self):
tag = extract_tags("Marbeth says,", CAST).tags[0]
assert tag.kind == "name"
assert tag.resolved is False
def test_pronoun_tag(self):
assert extract_tags("He says,", CAST).tags[0].kind == "pronoun"
def test_merged_row_returns_both_tags_and_the_passive_fragment(self):
scan = extract_tags("says the oldest The boy says,", CAST)
assert scan.has_unparseable
assert "oldest" in scan.passive_fragments
assert scan.tags[-1].kind == "role"
assert scan.tags[-1].head_noun == "boy"
assert [tag.head_noun for tag in scan.tags] == ["old", "boy"]
def test_verb_with_no_subject_is_unparseable(self):
scan = extract_tags("says", CAST)
assert scan.has_unparseable
assert scan.tags == []
def test_trailing_conjunction_is_rejected(self):
assert extract_tags("The robot and tells him,", CAST).tags == []
def test_role_with_unknown_addressee_is_not_reported_as_addressed(self):
""""for the king" names nobody in the cast, so it is not an addressee.
The tag value keeps only the person reference; the report prints the narration
verbatim beside it, so nothing the reviewer needs to read is lost.
"""
tag = extract_tags("The guard for the king says,", CAST).tags[0]
assert tag.addressee is None
assert tag.head_noun == "guard"
class TestCandidates:
def test_role_candidates_ignore_the_position_qualifier(self):
tag = extract_tags("The leader on the right for Hallr says,", CAST).tags[0]
found = candidate_speakers(tag, CAST, SCENE)
assert set(found) == {"Scum leader", "The Leader"}
def test_a_role_tag_reaches_a_proper_name_by_gender_not_by_name(self):
"""Hallr's name contains no "man", so "The man" reaches him only via the scene.
Without this fallback every "The woman says," tag would match nobody and the
correct speaker would look like an error - which is exactly what the first
book-wide run produced.
"""
tag = extract_tags("The man says,", CAST).tags[0]
assert "man" not in "hallr"
assert "Hallr" in candidate_speakers(tag, CAST, SCENE)
assert support("Hallr", tag, CAST, SCENE).verdict == "supported"
def test_name_candidate_is_the_canonical_name(self):
tag = extract_tags("Scum leader says,", CAST).tags[0]
assert candidate_speakers(tag, CAST, SCENE) == ("Scum leader",)
def test_pronoun_candidates_are_gender_filtered(self):
assert set(candidate_speakers(extract_tags("She says,", CAST).tags[0], CAST, SCENE)) == {
"Sheyla"
}
def test_unknown_pronoun_gender_is_not_narrowed(self):
found = candidate_speakers(extract_tags("They say,", CAST).tags[0], CAST, SCENE)
assert set(found) == set(SCENE)
class TestVerdicts:
def test_chapter6_utt29_is_contradicted(self):
"""The real defect: a leader's line given to Hallr.
Two profiles both carry "leader", so which leader is a question for the reviewer -
but the assignment is wrong either way, and that is the verdict.
"""
tag = extract_tags("The leader on the right for Hallr says,", CAST).tags[0]
verdict = support("Hallr", tag, CAST, SCENE)
assert verdict.verdict == "contradicted"
assert verdict.is_error
assert verdict.candidate_count == 2
assert not verdict.suggestion_is_confident
def test_a_real_leader_assignment_is_supported(self):
tag = extract_tags("The leader on the right for Hallr says,", CAST).tags[0]
assert support("Scum leader", tag, CAST, SCENE).verdict == "supported"
def test_chapter9_utt264_kid_attributed_to_the_king_is_contradicted(self):
""""Feliu." spoken by Feliu, behind the tag "The boy says,".
The tag's candidates are the males present, Feliu among them, so the assignment
stands. Attributing the same line to The King would not.
"""
tag = extract_tags("The boy says,", CAST).tags[0]
assert support("Feliu", tag, CAST, SCENE, text="\u201cFeliu.\u201d").verdict == "supported"
assert support("The King", tag, CAST, SCENE).verdict == "contradicted"
def test_royal_guard_under_the_guard_tag_is_supported(self):
tag = extract_tags("The guard says to Tobias,", CAST).tags[0]
assert support("Royal Guard", tag, CAST, SCENE).verdict == "supported"
def test_two_officers_make_the_replacement_a_question_not_a_verdict(self):
"""The operator's real case: The Officer and Officer are two characters."""
tag = extract_tags("The officer says,", CAST).tags[0]
verdict = support("Hallr", tag, CAST, SCENE)
assert verdict.verdict == "contradicted"
assert verdict.candidate_count == 2
assert not verdict.suggestion_is_confident
for name in ("The Officer", "Officer"):
assert support(name, tag, CAST, SCENE).verdict == "supported"
def test_a_missing_tag_is_unconstrained_and_never_an_error(self):
verdict = support("Hallr", None, CAST, SCENE)
assert verdict.verdict == "unconstrained"
assert not verdict.is_error
assert not verdict.needs_review
def test_a_role_matching_nobody_is_the_missed_speaker_signal(self):
""""The dragon says," with no dragon in the cast is a speaker never created."""
tag = extract_tags("The dragon says,", CAST).tags[0]
verdict = support("Hallr", tag, CAST, SCENE)
assert verdict.verdict == "unsupported"
assert verdict.needs_review
assert "never created" in verdict.note
def test_a_tag_naming_an_unknown_person_is_the_missed_speaker_signal(self):
tag = extract_tags("Marbeth says,", CAST).tags[0]
verdict = support("Hallr", tag, CAST, SCENE)
assert verdict.verdict == "unsupported"
assert "Marbeth" in verdict.note
def test_self_naming_line_is_never_an_error(self):
""""My name is Neriah" behind "The woman says," must not be called an error."""
tag = extract_tags("The woman says,", CAST).tags[0]
verdict = support(
"Neriah",
tag,
CAST,
SCENE,
text="\u201cMy name is Neriah. You\u2019re the first dwarf anyone has seen.\u201d",
)
assert verdict.verdict == "supported"
def test_narrator_is_never_a_claim_about_the_cast(self):
tag = extract_tags("The woman says,", CAST).tags[0]
assert support("narrator", tag, CAST, SCENE).verdict == "supported"
class TestNearestTag:
def _utts(self):
return [
{"type": "narration", "text": "It began.", "speaker_name": "narrator"},
{
"type": "narration",
"text": "The leader on the right for Hallr says,",
"speaker_name": "narrator",
},
{"type": "dialogue", "text": "A real dwarf.", "speaker_name": "Hallr"},
]
def test_finds_the_tag_immediately_back(self):
ctx = nearest_tag(self._utts(), 2, CAST)
tag, text = ctx.tag, ctx.narration
assert tag is not None
assert tag.head_noun == "leader"
assert "for Hallr" in (text or "")
def test_a_tag_never_spans_a_dialogue_line(self):
rows = [
{"type": "narration", "text": "The woman says,", "speaker_name": "narrator"},
{"type": "dialogue", "text": "One.", "speaker_name": "Sheyla"},
{"type": "dialogue", "text": "Two.", "speaker_name": "Sheyla"},
]
ctx = nearest_tag(rows, 2, CAST)
assert ctx.tag is None
assert ctx.mid_conversation
def test_scene_break_terminates_the_search(self):
rows = [
{"type": "narration", "text": "The woman says,", "speaker_name": "narrator"},
{"type": "scene_break", "text": "<<<>>>", "speaker_name": "narrator"},
{"type": "dialogue", "text": "One.", "speaker_name": "Sheyla"},
]
ctx = nearest_tag(rows, 2, CAST)
assert ctx.tag is None
assert ctx.mid_conversation
def test_a_passive_tag_is_a_tag(self):
""""says the oldest" means the oldest one speaks, so it is evidence, not nothing."""
rows = [
{"type": "narration", "text": "says the oldest", "speaker_name": "narrator"},
{"type": "dialogue", "text": "Feliu.", "speaker_name": "Feliu"},
]
ctx = nearest_tag(rows, 1, CAST)
assert ctx.tag is not None
assert ctx.tag.head_noun == "old"
assert ctx.narration == "says the oldest"
def test_a_bare_speech_verb_returns_the_text_and_no_tag(self):
rows = [
{"type": "narration", "text": "Hallr says", "speaker_name": "narrator"},
{"type": "dialogue", "text": "What?", "speaker_name": "Hallr"},
]
# A name tag with nothing after it still identifies the speaker.
assert nearest_tag(rows, 1, CAST).tag is not None
rows[0]["text"] = "The witch mutters something"
ctx = nearest_tag(rows, 1, CAST)
assert ctx.tag is not None and ctx.tag.head_noun == "witch"
class TestUnconstrained:
def test_a_line_inside_a_conversation_is_unconstrained_not_unsupported(self):
"""The majority of dialogue in a novel has no tag. That is not a finding."""
rows = [
{
"type": "narration",
"text": "The leader on the right for Hallr says,",
"speaker_name": "narrator",
},
{"type": "dialogue", "text": "One.", "speaker_name": "Scum leader"},
{"type": "dialogue", "text": "Two.", "speaker_name": "Scum leader"},
{"type": "dialogue", "text": "Three.", "speaker_name": "Scum leader"},
]
ctx = nearest_tag(rows, 3, CAST)
assert ctx.mid_conversation
verdict = support("Scum leader", ctx.tag, CAST, ["Scum leader"], context=ctx)
assert verdict.verdict == "unconstrained"
assert not verdict.needs_review
def test_unparsed_narration_is_unconstrained_but_flagged_as_segmentation(self):
rows = [
{"type": "narration", "text": "says the", "speaker_name": "narrator"},
{"type": "dialogue", "text": "One.", "speaker_name": "Hallr"},
]
ctx = nearest_tag(rows, 1, CAST)
assert ctx.unparsed
assert not ctx.mid_conversation
verdict = support("Hallr", ctx.tag, CAST, ["Hallr"], context=ctx)
assert verdict.verdict == "unconstrained"
assert "segmentation" in verdict.note
class TestNormalisation:
def test_irregular_plurals_reach_the_singular_role_word(self):
""""one of the six men" has to reach "man" to match a male speaker."""
tag = extract_tags("One of the six men says,", CAST).tags[0]
assert tag.head_noun == "man"
def test_a_possessive_voice_names_the_person_not_the_voice(self):
""""The woman's voice says," is a woman, not one of the profiles called Voice."""
tag = extract_tags("The woman\u2019s voice says,", CAST).tags[0]
assert tag.kind == "role"
assert tag.head_noun == "woman"
assert support("Sheyla", tag, CAST, SCENE).verdict == "supported"
def test_a_named_possessive_voice_resolves_to_that_name(self):
tag = extract_tags("Sheyla\u2019s voice says,", CAST).tags[0]
assert (tag.kind, tag.value, tag.resolved) == ("name", "Sheyla", True)
def test_the_possessive_voice_tail_is_dropped_from_the_tag(self):
"""The tag names the woman, not the voice. The narration is shown beside it."""
tag = extract_tags("The woman\u2019s voice says,", CAST).tags[0]
assert tag.value == "The woman"
assert "voice" not in tag.value
class TestNarrativeConstructs:
"""Shapes measured on Last Dwarf that each produced a wrong tag before being fixed."""
def test_a_subordinating_conjunction_opens_a_new_clause(self):
""""The dragon swallows when Hallr says," is Hallr. Reading the dragon is wrong."""
row = "The dragon puts its head back and swallows when Hallr says,"
tag = extract_tags(row, CAST).tags[0]
assert (tag.kind, tag.value) == ("name", "Hallr")
assert support("Hallr", tag, CAST, SCENE).verdict == "supported"
assert support("The King", tag, CAST, SCENE).verdict == "contradicted"
def test_a_leading_participle_is_not_the_speaker(self):
""""Nodding Tobias says," once produced a speaker called "Nodding"."""
assert extract_tags("Nodding Tobias says,", CAST).tags == []
def test_a_leading_adverb_is_not_the_speaker(self):
assert extract_tags("Now Milfred says,", CAST).tags == []
def test_a_trailing_participial_clause_is_not_the_subject(self):
""", Feliu comes running from the back of the wagon, saying," is Feliu."""
row = ", Feliu comes running from the back of the wagon, saying,"
tag = extract_tags(row, CAST).tags[0]
assert (tag.kind, tag.value) == ("name", "Feliu")
def test_a_coordinated_predicate_speaks_as_its_last_subject(self):
""""Sheyla shakes her head, and Tanisha says," is Tanisha, not Sheyla."""
tag = extract_tags("Sheyla shakes her head, and Tanisha says,", CAST).tags[0]
assert (tag.kind, tag.value) == ("name", "Tanisha")
def test_an_action_after_the_head_noun_ends_the_reference(self):
""""The kid licks his lips" is The Kid, not a role phrase about lip-licking."""
row = "The kid licks his lips, with a breath, says,"
tag = extract_tags(row, CAST).tags[0]
assert tag.value == "The kid"
assert support("The Kid", tag, CAST, SCENE).verdict == "supported"
def test_a_composed_designation_keeps_both_head_nouns(self):
""""The oldest girl" must not truncate to "The oldest"."""
tag = extract_tags("The oldest girl says,", CAST).tags[0]
assert tag.value == "The oldest girl"
assert tag.head_noun == "girl"
def test_a_gendered_role_tag_never_matches_by_name(self):
"""Hallr has no "man" in his name, so this only works through the scene."""
tag = extract_tags("The man says,", CAST).tags[0]
assert "Hallr" not in tag.value.lower()
assert support("Hallr", tag, CAST, SCENE).verdict == "supported"