Skip to content

Commit cc4b5f8

Browse files
TarahAssistantTarahAssistant
andauthored
feat: honor canonical lexemes in consolidated export (#657)
Co-authored-by: TarahAssistant <265369510+ArdeleanLucas@users.noreply.github.com>
1 parent c3984f4 commit cc4b5f8

3 files changed

Lines changed: 131 additions & 2 deletions

File tree

python/ai/tools/export_tools.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -653,7 +653,8 @@ def export_lingpy_tsv(tools: "ParseChatTools", args: Dict[str, Any]) -> Dict[str
653653
try:
654654
from compare.consolidated_matrix import build_wordlist_rows, wordlist_rows_to_tsv
655655
cognate_sets, meta, id_to_key, _speakers, allowed_ids = _consolidated_sets(tools, concept_tag)
656-
rows = build_wordlist_rows(tools.annotations_dir, cognate_sets, id_to_key, allowed_ids)
656+
canonical_lexemes = (_read_json_file(tools.enrichments_path, {}).get("manual_overrides") or {}).get("canonical_lexemes")
657+
rows = build_wordlist_rows(tools.annotations_dir, cognate_sets, id_to_key, allowed_ids, canonical_lexemes)
657658
content = wordlist_rows_to_tsv(rows)
658659
except ChatToolError:
659660
raise

python/ai/tools/test_consolidated_export_wiring.py

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,14 @@
66
import re
77
import sys
88
import xml.etree.ElementTree as ET
9+
from typing import Any
910

1011
import pytest
1112

1213
sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[2]))
1314

1415
from ai.chat_tools import ParseChatTools # noqa: E402 (import first; avoids export_tools load cycle)
16+
from ai.workflow_tools import WorkflowTools # noqa: E402
1517

1618

1719
def _seed_workspace(root: pathlib.Path) -> None:
@@ -53,6 +55,73 @@ def write(speaker: str, concept_id: str, ipa: str) -> None:
5355
write("Spk3", "150", "kalan") # tagged under the duplicate JBIL id
5456

5557

58+
def _write_annotation(root: pathlib.Path, speaker: str, intervals: list[dict[str, Any]]) -> None:
59+
ann = root / "annotations"
60+
ann.mkdir(exist_ok=True)
61+
concept_intervals = []
62+
ipa_intervals = []
63+
for index, item in enumerate(intervals):
64+
start = float(index)
65+
end = start + 0.5
66+
concept_intervals.append(
67+
{"text": item.get("label", ""), "concept_id": item["concept_id"], "start": start, "end": end}
68+
)
69+
ipa_intervals.append({"text": item["ipa"], "start": start, "end": end})
70+
(ann / f"{speaker}.parse.json").write_text(
71+
json.dumps({"speaker": speaker, "tiers": {"concept": {"intervals": concept_intervals}, "ipa": {"intervals": ipa_intervals}}}),
72+
encoding="utf-8",
73+
)
74+
75+
76+
def _seed_roundtrip_workspace(root: pathlib.Path) -> None:
77+
(root / "concepts.csv").write_text(
78+
"id,concept_en,source_survey,source_item\n"
79+
"101,hand,KLQ,1.1\n"
80+
"201,hand,JBIL,101\n"
81+
"102,foot,KLQ,1.2\n"
82+
"202,foot,JBIL,102\n"
83+
"103,salt,KLQ,1.3\n"
84+
"203,salt,JBIL,103\n",
85+
encoding="utf-8",
86+
)
87+
groups = {"A": ["Spk1", "Spk2"], "B": ["Spk3"]}
88+
(root / "parse-enrichments.json").write_text(
89+
json.dumps({
90+
"manual_overrides": {
91+
"cognate_sets": {
92+
"101": groups,
93+
"201": dict(groups),
94+
"102": {"A": ["Spk1"], "B": ["Spk2", "Spk3"]},
95+
"202": {"A": ["Spk1"], "B": ["Spk2", "Spk3"]},
96+
# Disagreement safety: this overlapping gloss must stay split.
97+
"103": {"A": ["Spk1", "Spk2"], "B": ["Spk3"]},
98+
"203": {"A": ["Spk1"], "B": ["Spk2", "Spk3"]},
99+
},
100+
# Spk1 has both survey forms for hand; the explicit canonical
101+
# choice should select row 201 rather than the earlier 101 form.
102+
"canonical_lexemes": {"bundle:hand": {"Spk1": {"csv_row_id": "201", "source": "manual"}}},
103+
}
104+
}),
105+
encoding="utf-8",
106+
)
107+
(root / "project.json").write_text(
108+
json.dumps({"speakers": {"Spk1": {}, "Spk2": {}, "Spk3": {}}}), encoding="utf-8"
109+
)
110+
_write_annotation(
111+
root,
112+
"Spk1",
113+
[
114+
{"concept_id": "101", "label": "hand", "ipa": "old-hand"},
115+
{"concept_id": "201", "label": "hand", "ipa": "chosen-hand"},
116+
{"concept_id": "102", "label": "foot", "ipa": "foot-one"},
117+
{"concept_id": "103", "label": "salt", "ipa": "salt-one"},
118+
{"concept_id": "203", "label": "salt", "ipa": "salt-two"},
119+
],
120+
)
121+
_write_annotation(root, "Spk2", [{"concept_id": "201", "label": "hand", "ipa": "hand-two"}, {"concept_id": "202", "label": "foot", "ipa": "foot-two"}])
122+
_write_annotation(root, "Spk3", [{"concept_id": "101", "label": "hand", "ipa": "hand-three"}, {"concept_id": "202", "label": "foot", "ipa": "foot-three"}])
123+
124+
56125
def test_export_nexus_consolidates_and_tag_filters(tmp_path: pathlib.Path) -> None:
57126
_seed_workspace(tmp_path)
58127
tools = ParseChatTools(project_root=tmp_path)
@@ -115,3 +184,35 @@ def test_export_beast2_xml_rejects_bad_chain_length(tmp_path: pathlib.Path) -> N
115184
tools = ParseChatTools(project_root=tmp_path)
116185
with pytest.raises(Exception):
117186
tools._tool_export_beast2_xml({"consolidate": True, "chainLength": 0, "dryRun": True})
187+
188+
189+
def test_complete_export_consolidate_roundtrip_collapses_n_not_2n(tmp_path: pathlib.Path) -> None:
190+
_seed_roundtrip_workspace(tmp_path)
191+
workflow = WorkflowTools(project_root=tmp_path)
192+
193+
payload = workflow.execute(
194+
"export_complete_lingpy_dataset",
195+
{"with_contact_lexemes": False, "consolidate": True, "outputDir": "exports/roundtrip", "dryRun": False},
196+
)["result"]
197+
198+
nexus_text = (tmp_path / "exports" / "roundtrip" / "dataset.nex").read_text(encoding="utf-8")
199+
wordlist_text = (tmp_path / "exports" / "roundtrip" / "wordlist.tsv").read_text(encoding="utf-8")
200+
nchar = int(re.search(r"NCHAR=(\d+)", nexus_text).group(1))
201+
concept_values = [line.split("\t")[1] for line in wordlist_text.splitlines()[1:]]
202+
hand_rows = [line.split("\t") for line in wordlist_text.splitlines()[1:] if line.split("\t")[1] == "hand"]
203+
204+
assert payload["consolidated"] is True
205+
assert payload["stages"][0]["payload"]["consolidation"]["collapsed_groups"] == 2
206+
assert payload["stages"][0]["payload"]["consolidation"]["concept_count"] == 4
207+
assert payload["stages"][0]["payload"]["consolidation"]["needs_recluster_groups"] == 1
208+
assert any("kept separate" in warning for warning in payload["warnings"])
209+
# N=2 safe overlapping meanings (hand + foot) produce N consolidated concept
210+
# blocks, not 2N per-survey duplicates; disagreeing salt remains split.
211+
assert "hand#" not in nexus_text
212+
assert "foot#" not in nexus_text
213+
assert "salt#103" in nexus_text and "salt#203" in nexus_text
214+
assert nchar == 8 # hand(2) + foot(2) + safe split salt#103(2) + salt#203(2)
215+
assert concept_values.count("hand") == 3
216+
assert len({row[2] for row in hand_rows}) == len(hand_rows)
217+
assert any(row[3] == "chosen-hand" for row in hand_rows)
218+
assert all(row[3] != "old-hand" for row in hand_rows)

python/compare/consolidated_matrix.py

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -238,6 +238,7 @@ def build_wordlist_rows(
238238
cognate_sets: CognateSets,
239239
id_to_key: Mapping[str, str],
240240
allowed_ids: Optional[Set[str]] = None,
241+
canonical_lexemes: Optional[Mapping[str, Any]] = None,
241242
) -> List[Tuple[Any, ...]]:
242243
"""Build LingPy wordlist rows remapped onto consolidated canonical concepts.
243244
@@ -248,6 +249,22 @@ def build_wordlist_rows(
248249
forms_by_concept, _speakers = load_annotations(annotations_dir)
249250
cogid_lookup, cogid_group_by_speaker = _build_cogid_lookup(cognate_sets)
250251

252+
selected_row_by_key_speaker: Dict[Tuple[str, str], str] = {}
253+
if isinstance(canonical_lexemes, Mapping):
254+
for speaker_map in canonical_lexemes.values():
255+
if not isinstance(speaker_map, Mapping):
256+
continue
257+
for speaker, selection in speaker_map.items():
258+
if not isinstance(selection, Mapping):
259+
continue
260+
row_id = str(selection.get("csv_row_id") or "").strip()
261+
if not row_id:
262+
continue
263+
key = id_to_key.get(row_id)
264+
if not key:
265+
continue
266+
selected_row_by_key_speaker[(key, _speaker_lookup_key(str(speaker)))] = row_id
267+
251268
# Remap and de-dup forms onto canonical gloss keys.
252269
by_key_speaker: Dict[Tuple[str, str], Any] = {}
253270
for concept_id, records in forms_by_concept.items():
@@ -258,7 +275,17 @@ def build_wordlist_rows(
258275
sk = _speaker_lookup_key(record.speaker)
259276
slot = (key, sk)
260277
existing = by_key_speaker.get(slot)
261-
if existing is None or record.start_sec < existing.start_sec:
278+
selected_row_id = selected_row_by_key_speaker.get(slot)
279+
if existing is None:
280+
by_key_speaker[slot] = record
281+
elif selected_row_id:
282+
record_is_selected = str(record.concept_id) == selected_row_id
283+
existing_is_selected = str(existing.concept_id) == selected_row_id
284+
if record_is_selected and not existing_is_selected:
285+
by_key_speaker[slot] = record
286+
elif record_is_selected == existing_is_selected and record.start_sec < existing.start_sec:
287+
by_key_speaker[slot] = record
288+
elif record.start_sec < existing.start_sec:
262289
by_key_speaker[slot] = record
263290

264291
forms_by_key: Dict[str, List[Any]] = {}

0 commit comments

Comments
 (0)