Stop labelling a model guess as a read tail

_annotate_speaker_methods stamped `tail`, the highest-trust provenance, on any
line whose speaker matched a present local_id, at gemma's confidence of 1.0. No
balloon was read. Three of three sampled two-character panels had the speakers
swapped, so a multi-character guess is now dropped to unknown, and a solo-panel
guess is kept as model_solo at 0.7.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-11 23:23:56 +04:00
parent 36c7cc946f
commit e8941d8ceb
7 changed files with 133 additions and 39 deletions
+23 -3
View File
@@ -374,16 +374,31 @@ def resolve_speakers(dialogue: list, present: list) -> list:
def _annotate_speaker_methods(dialogue: list, present: list) -> list:
"""Fill provenance for model-attributed lines without overwriting grounded/backstop methods."""
"""Fill provenance for model-attributed lines without overwriting grounded/backstop methods.
A speaker matching a present local_id used to be stamped `tail`, the highest-trust label, at
gemma's own confidence of 1.0. No balloon geometry was ever read. On the 2026-08-11 chapter every
sampled two-character panel had the speakers swapped
(`caveats/speaker-attribution.md#tail-is-not-geometry`), so with 2+ present the guess is dropped
rather than shipped as truth. With one present it is the same claim as the solo backstop, so it is
kept and named for what it is.
ponytail: drop-on-crowd is the honest floor, not the fix. Bind by tail geometry when the balloon
detector lands, then this branch reads a tail for real."""
local_ids = {c.get("local_id") for c in present if c.get("local_id")}
crowded = len(present) > 1
for d in dialogue:
if d.get("speaker_method"):
continue
speaker = (d.get("speaker") or "").strip()
if d.get("type", "speech") not in _SPEECH or not speaker or speaker == "unknown":
d["speaker_method"] = "unknown"
elif speaker in local_ids and crowded:
d["speaker"] = "unknown"
d["confidence"] = 0.0
d["speaker_method"] = "unknown"
elif speaker in local_ids:
d["speaker_method"] = "tail"
d["speaker_method"] = "model_solo"
d["confidence"] = min(float(d.get("confidence") or 0.7), 0.7)
else:
d["speaker_method"] = "turn_taking"
return dialogue
@@ -976,7 +991,12 @@ if __name__ == "__main__":
two = [{"local_id": "person_1"}, {"local_id": "person_2"}]
assert resolve_speakers([{"speaker": "unknown", "type": "speech", "text": "x"}], two)[0]["speaker"] == "unknown"
assert resolve_speakers([{"speaker": "unknown", "type": "speech", "text": "x"}], [])[0]["speaker"] == "unknown"
assert resolve_speakers([{"speaker": "person_1", "type": "speech", "text": "x"}], two)[0]["speaker_method"] == "tail"
# a present-local_id match is gemma's guess, not a read tail: dropped when 2+ are present,
# kept but capped at 0.7 when only one is (caveats/speaker-attribution.md#tail-is-not-geometry)
crowd = resolve_speakers([{"speaker": "person_1", "type": "speech", "text": "x", "confidence": 1.0}], two)[0]
assert crowd["speaker"] == "unknown" and crowd["speaker_method"] == "unknown" and crowd["confidence"] == 0.0
lone = resolve_speakers([{"speaker": "person_1", "type": "speech", "text": "x", "confidence": 1.0}], solo)[0]
assert lone["speaker"] == "person_1" and lone["speaker_method"] == "model_solo" and lone["confidence"] == 0.7
# set-of-mark: gemma answers a face label -> remapped to local_id; a name/unknown passes through
lbl = _apply_speaker_labels([{"speaker": "P1"}, {"speaker": "Aria"}, {"speaker": "unknown"}],
{"P1": "person_3"})