1010import argparse
1111import csv
1212import json
13+ import re
1314import sys
1415import unicodedata
1516from dataclasses import dataclass , field
@@ -781,46 +782,25 @@ def _group_label(index: int) -> str:
781782def _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
826806def _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
0 commit comments