377 lines
17 KiB
Python
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"
|