Skip to content

Commit 97a84fc

Browse files
TrueNorth49claude
andauthored
[MC-462-C] fix(compare): make LexStat authoritative on un-clusterable forms (#653)
#651 hardened the cognate job by *predicting* which forms LingPy would reject (a sound-class pre-check). The prediction had false negatives: a "/" variant separator (e.g. "qap/gaza girtin") survives tokenising as an unknown segment that LexStat rejects, so the job still crashed with "Could not convert item ID: <row>" on larger speaker selections (observed at row 3235 with a 14-speaker run). Replace the guess with two robust layers: 1. _lingpy_safe_form now also folds "/" into the "_" word boundary, so variant forms tokenise and still cluster (no longer dropped). 2. LexStat itself is the authority: build the wordlist, and if it raises "Could not convert item ID: <row>", drop that row, rebuild with fresh CONTIGUOUS ids, and retry. This cannot crash on any unforeseen character (deleting a key in place leaves a gap that corrupts LingPy's row numbering, hence the full rebuild). The sound-class pre-check helper is removed. Skipped forms are still recorded/surfaced via the #652 collector. No change to persisted enrichments or displayed forms. Verified against a full real workspace (14 speakers / 3464 forms) that previously crashed at row 3235: now completes, the "/"-variant form clusters, and only the lone "?" placeholder is skipped. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 702c500 commit 97a84fc

2 files changed

Lines changed: 81 additions & 57 deletions

File tree

python/compare/cognate_compute.py

Lines changed: 53 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
import argparse
1111
import csv
1212
import json
13+
import re
1314
import sys
1415
import unicodedata
1516
from dataclasses import dataclass, field
@@ -781,46 +782,25 @@ def _group_label(index: int) -> str:
781782
def _lingpy_safe_form(ipa_form: str) -> str:
782783
"""Make a form safe for LingPy's tokenizer without altering stored data.
783784
784-
LingPy's ``ipa2tokens`` raises ``ValueError: Input must not contain
785-
spaces`` on multi-word forms (e.g. Kurdish light-verb constructions
786-
like ``"qap girtin"``), which LexStat surfaces as the opaque
787-
``Could not convert item ID: <row>`` error and aborts the whole
788-
cognate job. Collapse internal whitespace to ``_`` -- LingPy's own
789-
word-boundary symbol -- so the form tokenizes cleanly.
785+
Two real-world form shapes abort the whole cognate job with the opaque
786+
``Could not convert item ID: <row>`` error:
787+
788+
- **multi-word forms** (e.g. light-verb constructions like
789+
``"qap girtin"``) -- ``ipa2tokens`` rejects spaces outright;
790+
- **``/`` variant separators** (e.g. ``"qap/gaza girtin"``) -- the
791+
``/`` survives tokenisation as an unknown segment that LexStat then
792+
refuses to convert.
793+
794+
Collapse internal whitespace *and* ``/`` to ``_`` -- LingPy's own
795+
word-boundary symbol -- so both shapes tokenize cleanly and still
796+
cluster.
790797
791798
This value is fed ONLY to the in-memory LexStat wordlist. It is never
792799
persisted to parse-enrichments.json nor shown in the UI: the cognate
793800
output is speaker groupings only, and similarity scores use the
794801
original ``record.ipa``. The displayed form is unchanged.
795802
"""
796-
return "_".join(str(ipa_form or "").split())
797-
798-
799-
def _lingpy_clusterable(safe_form: str, ipa2tokens, tokens2class) -> bool:
800-
"""Return True if LingPy can tokenise ``safe_form`` into known sounds.
801-
802-
A few stored form values are un-clusterable even after whitespace
803-
sanitising -- e.g. a bare ``"?"`` placeholder that tokenises to
804-
only-unknown sound classes. LexStat aborts the entire job on such a
805-
form (``Could not convert item ID: <row>``). Detecting them up front
806-
lets us build a gap-free wordlist and exclude only the offending rows
807-
-- deleting keys after construction instead corrupts LingPy's row
808-
numbering and triggers a misleading "contains N fields" error.
809-
810-
Excluded rows keep their stored/displayed form untouched; they simply
811-
receive no cognate grouping (a ``"?"`` placeholder is not cognate with
812-
anything). Any tokeniser exception is treated as un-clusterable.
813-
"""
814-
if not safe_form:
815-
return False
816-
try:
817-
tokens = ipa2tokens(safe_form)
818-
if not tokens:
819-
return False
820-
classes = tokens2class(tokens, "sca")
821-
except Exception:
822-
return False
823-
return any(cls != "0" for cls in classes)
803+
return "_".join(str(ipa_form or "").replace("/", " ").split())
824804

825805

826806
def _compute_cognate_sets_with_lingpy(
@@ -839,7 +819,6 @@ def _compute_cognate_sets_with_lingpy(
839819
"""
840820
try:
841821
from lingpy import LexStat # type: ignore
842-
from lingpy.sequence.sound_classes import ipa2tokens, tokens2class # type: ignore
843822
except ImportError as exc:
844823
raise RuntimeError(
845824
"LingPy is not installed. Install it with: pip install lingpy"
@@ -855,29 +834,48 @@ def _compute_cognate_sets_with_lingpy(
855834
if not rows:
856835
return {}
857836

858-
# Build the LexStat wordlist with CONTIGUOUS integer row IDs. Forms
859-
# are whitespace-sanitised (so multi-word forms still cluster) and any
860-
# form LingPy cannot tokenise into known sounds is skipped up front --
861-
# excluding them after construction would leave gaps in the row
862-
# numbering and crash LingPy. Skipped rows keep their displayed form
863-
# and simply receive no cognate grouping.
864-
lex_data: Dict[int, List[str]] = {0: ["doculect", "concept", "ipa"]}
865-
index_meta: Dict[int, Tuple[str, str]] = {}
866-
867-
row_index = 0
837+
# Sanitise forms (whitespace/"/" -> "_", so multi-word and variant
838+
# forms still cluster) and drop empties. Each surviving candidate
839+
# carries its original form so we can report it if LexStat rejects it.
840+
candidates: List[Tuple[str, str, str, str, str]] = []
868841
skipped: List[Dict[str, str]] = []
869842
for speaker, concept_label, ipa_form, concept_id in rows:
870843
safe_form = _lingpy_safe_form(ipa_form)
871-
if not _lingpy_clusterable(safe_form, ipa2tokens, tokens2class):
872-
skipped.append({
873-
"concept_id": concept_id,
874-
"speaker": speaker,
875-
"form": str(ipa_form or ""),
876-
})
844+
original = str(ipa_form or "")
845+
if not safe_form:
846+
skipped.append({"concept_id": concept_id, "speaker": speaker, "form": original})
877847
continue
878-
row_index += 1
879-
lex_data[row_index] = [speaker, concept_label, safe_form]
880-
index_meta[row_index] = (concept_id, speaker)
848+
candidates.append((speaker, concept_label, safe_form, concept_id, original))
849+
850+
# Let LexStat itself be the authority on what it can ingest. A few
851+
# values survive sanitising yet LexStat still refuses to convert them
852+
# (e.g. a bare "?" that tokenises to only-unknown sounds), aborting
853+
# with "Could not convert item ID: <row>". When that happens, drop the
854+
# offending row and rebuild the wordlist with fresh CONTIGUOUS ids,
855+
# then retry -- deleting a key in place instead leaves a gap that
856+
# corrupts LingPy's row numbering. Bounded by the candidate count.
857+
lexstat = None
858+
index_meta: Dict[int, Tuple[str, str]] = {}
859+
for _attempt in range(len(candidates) + 1):
860+
lex_data: Dict[int, List[str]] = {0: ["doculect", "concept", "ipa"]}
861+
index_meta = {}
862+
for idx, (speaker, concept_label, safe_form, concept_id, _orig) in enumerate(candidates, start=1):
863+
lex_data[idx] = [speaker, concept_label, safe_form]
864+
index_meta[idx] = (concept_id, speaker)
865+
if not index_meta:
866+
break
867+
try:
868+
lexstat = LexStat(lex_data, check=False)
869+
break
870+
except ValueError as exc:
871+
match = re.search(r"convert item ID:\s*(\d+)", str(exc))
872+
if match is None:
873+
raise
874+
bad = int(match.group(1))
875+
if not 1 <= bad <= len(candidates):
876+
raise
877+
speaker, concept_label, safe_form, concept_id, original = candidates.pop(bad - 1)
878+
skipped.append({"concept_id": concept_id, "speaker": speaker, "form": original})
881879

882880
if skipped:
883881
_warn(
@@ -893,10 +891,9 @@ def _compute_cognate_sets_with_lingpy(
893891
if skipped_out is not None:
894892
skipped_out.extend(skipped)
895893

896-
if not index_meta: # nothing clusterable
894+
if lexstat is None or not index_meta: # nothing clusterable
897895
return {}
898896

899-
lexstat = LexStat(lex_data, check=False)
900897
lexstat.get_scorer()
901898
lexstat.cluster(method="lexstat", threshold=float(threshold), ref="cogid")
902899

python/compare/test_cognate_lingpy_safe.py

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,10 +37,16 @@ def test_safe_form_collapses_internal_space():
3737

3838

3939
def test_safe_form_handles_multiple_and_repeated_spaces():
40-
assert _lingpy_safe_form("qap/gaza girtin") == "qap/gaza_girtin"
4140
assert _lingpy_safe_form("a b\tc") == "a_b_c"
4241

4342

43+
def test_safe_form_collapses_slash_variant_separator():
44+
# A "/" variant separator survives ipa2tokens as an unknown segment
45+
# that LexStat rejects; fold it into the "_" word boundary too.
46+
assert _lingpy_safe_form("qap/gaza girtin") == "qap_gaza_girtin"
47+
assert _lingpy_safe_form("a/b") == "a_b"
48+
49+
4450
def test_safe_form_leaves_single_word_unchanged():
4551
assert _lingpy_safe_form("xwārdin") == "xwārdin"
4652

@@ -161,3 +167,24 @@ def test_skipped_out_stays_empty_when_all_clusterable():
161167
_compute_cognate_sets_with_lingpy(forms_by_concept, concepts, 0.6, skipped_out=skipped)
162168

163169
assert skipped == []
170+
171+
172+
def test_slash_variant_form_clusters_and_is_not_skipped():
173+
pytest.importorskip("lingpy")
174+
175+
# "/"-variant forms previously survived sanitising but were rejected by
176+
# LexStat ("Could not convert item ID"). They must now cluster, not skip.
177+
forms_by_concept = {
178+
"627": [
179+
FormRecord("Badr01", "627", "", "qap/gaza girtin", "", 0.0, 1.0),
180+
FormRecord("Fail03", "627", "", "qap girtin", "", 0.0, 1.0),
181+
],
182+
}
183+
concepts = [ConceptSpec(concept_id="627")]
184+
185+
skipped: list = []
186+
result = _compute_cognate_sets_with_lingpy(forms_by_concept, concepts, 0.6, skipped_out=skipped)
187+
188+
assert skipped == []
189+
speakers = {s for group in result.get("627", {}).values() for s in group}
190+
assert speakers == {"Badr01", "Fail03"}

0 commit comments

Comments
 (0)