""" generate_patterns.py Genere des motifs de cesure (patterns TeX, algorithme de Liang) pour le malgache a partir d'un corpus de mots annotes au format "ma1la2ga3sy". Convention d'annotation : - un chiffre entre deux lettres = point de cesure autorise, pondere par sa valeur - chiffres impairs = coupure encouragee, chiffres pairs = coupure interdite (convention Liang standard, cf. hyphen.tex / patgen) Sorties : - tex/hyph-mg.tex : motifs bruts (\\patterns{...}) - tex/hyphmg-test.ini : format INITEX autonome pour les tests (test/*.tex), avec \\newlanguage dynamique. La PRODUCTION utilise \\addlanguage via l'integration hyph-utf8, pas ce fichier. """ from __future__ import annotations import re from dataclasses import dataclass, field from pathlib import Path from typing import NamedTuple # --------------------------------------------------------------------------- # Parsing partage de la notation Liang # --------------------------------------------------------------------------- def parse_liang_notation(raw: str) -> tuple[str, list[int]]: """Decompose une chaine annotee a la Liang (ex: 'ma1la2ga3sy' ou un motif de PatternRule) en (lettres_seules, vecteur_de_poids).""" letters = re.sub(r"\d", "", raw) values = [0] * (len(letters) + 1) pos, i = 0, 0 while i < len(raw): ch = raw[i] if ch.isdigit(): values[pos] = int(ch) i += 1 else: pos += 1 i += 1 return letters, values # --------------------------------------------------------------------------- # Modeles de donnees # --------------------------------------------------------------------------- @dataclass(frozen=True) class AnnotatedWord: """Un mot malgache annote avec ses points de cesure (notation Liang).""" raw: str # ex: "ma1la2ga3sy" def letters(self) -> str: return parse_liang_notation(self.raw)[0] def hyphen_values(self) -> list[int]: return parse_liang_notation(self.raw)[1] @dataclass class PatternRule: """Un motif de cesure au format TeX, ex: 'ndr1a' ou '.ma1la'.""" pattern: str source_words: set[str] = field(default_factory=set) def to_tex(self) -> str: return self.pattern class ClusterViolation(NamedTuple): """Une coupure encouragee trouvee a l'interieur d'un cluster protege.""" pattern: str cluster: str position: int # index (en lettres) ou le cluster commence class OverrideContradiction(NamedTuple): """Un override manuel qui contredit un motif regenere depuis le corpus pour la meme sequence de lettres.""" override_pattern: str corpus_pattern: str position: int override_value: int corpus_value: int # --------------------------------------------------------------------------- # Extraction des sous-motifs (algorithme de Liang simplifie) # --------------------------------------------------------------------------- class PatternExtractor: """ Extrait, a partir de mots annotes, un ensemble de motifs de cesure. Deux modes, choisis via whole_word_only : - whole_word_only=True (DEFAUT, sur depuis le 2026-09-02) : emet un seul motif par mot, ancre aux deux bouts par le caractere de frontiere (".mot."). Un tel motif ne peut matcher QUE ce mot exact dans son integralite -- aucune contamination croisee possible entre mots. Contrepartie assumee : generalisation NULLE a des mots absents du corpus (ils restent simplement non hyphenes, ce qui est le comportement de repli sur si TeX ne trouve aucun motif). - whole_word_only=False (ANCIEN COMPORTEMENT, DANGEREUX EN L'ETAT) : extrait toutes les sous-chaines de longueur min_len a max_len. CONFIRME PAR EXECUTION REELLE (2026-09-02) : provoque une contamination croisee -- des mots absents du corpus se retrouvent hyphenes a tort par coincidence de sous-chaines courtes (ex. "malagasy" -> "ma-la-ga-sy" via un fragment "la" extrait de "vola"). A ne reactiver qu'apres implementation d'un vrai algorithme d'elagage (patgen ou equivalent) -- voir docs/methodology.md section 4. """ def __init__( self, min_len: int = 2, max_len: int = 6, boundary_char: str = ".", whole_word_only: bool = True, ) -> None: self.min_len = min_len self.max_len = max_len self.boundary_char = boundary_char self.whole_word_only = whole_word_only self._patterns: dict[str, PatternRule] = {} def add_word(self, word: AnnotatedWord) -> None: letters = self.boundary_char + word.letters() + self.boundary_char values = [0] + word.hyphen_values() + [0] n = len(letters) if self.whole_word_only: pattern_str = self._interleave(letters, values, 0, n) if pattern_str is not None: rule = self._patterns.setdefault(pattern_str, PatternRule(pattern=pattern_str)) rule.source_words.add(word.raw) return for length in range(self.min_len, self.max_len + 1): for start in range(0, n - length + 1): substr = letters[start:start + length] if substr.count(self.boundary_char) > 1: continue pattern_str = self._interleave(substr, values, start, length) if pattern_str is None: continue rule = self._patterns.setdefault(pattern_str, PatternRule(pattern=pattern_str)) rule.source_words.add(word.raw) @staticmethod def _interleave(substr: str, values: list[int], start: int, length: int) -> str | None: out = [] has_info = False for i, ch in enumerate(substr): out.append(ch) v = values[start + i + 1] if v != 0: out.append(str(v)) has_info = True return "".join(out) if has_info else None def patterns(self) -> list[PatternRule]: return sorted(self._patterns.values(), key=lambda r: r.pattern) # --------------------------------------------------------------------------- # Regles specifiques au malgache (clusters, morphologie) # --------------------------------------------------------------------------- class MalagasyPhonotactics: """ Contraintes phonotactiques du malgache : clusters consonantiques jamais scindes. Base sur Dahl (1952) / O'Neill (2015) -- les consonnes prenasalisees (mp, mb, nk, ng, nd, ndr, nj, nts) et les digraphes/trigraphes (ts, tr, dr) forment des unites phonemiques uniques en attaque de syllabe. Le cluster "ntsar" mentionne a l'origine du projet n'a pas ete confirme comme unite autonome dans la litterature -- traite comme n+ts (deja couvert) en attendant validation par un locuteur natif ou l'Academia Malgache. """ UNBREAKABLE_CLUSTERS: tuple[str, ...] = ( "ts", "dr", "tr", "ndr", "nd", "mp", "mb", "nk", "ng", "nj", "nts", ) KNOWN_PREFIXES: tuple[str, ...] = ( "an", "am", "mank", "maha", "mpi", "fi", "faha", "voa", "man", "mi", ) def force_cluster_integrity(self, rules: list[PatternRule]) -> list[PatternRule]: return [r for r in rules if not self.find_violations(r.pattern)] def find_violations(self, pattern: str) -> list[ClusterViolation]: """Renvoie toutes les violations de clusters proteges dans un motif OU un mot annote complet (meme format).""" letters_only = re.sub(r"\d", "", pattern) violations = [] for cluster in self.UNBREAKABLE_CLUSTERS: start = 0 while (idx := letters_only.find(cluster, start)) != -1: if self.has_forbidden_break(pattern, idx, len(cluster)): violations.append(ClusterViolation(pattern, cluster, idx)) start = idx + 1 return violations @staticmethod def has_forbidden_break(pattern: str, start: int, cluster_len: int) -> bool: """True si un chiffre impair (coupure encouragee) tombe strictement entre les lettres [start, start+cluster_len) du motif.""" letter_pos = 0 i = 0 while i < len(pattern): ch = pattern[i] if ch.isdigit(): if start < letter_pos < start + cluster_len and int(ch) % 2 == 1: return True else: letter_pos += 1 i += 1 return False def find_override_contradictions( manual_overrides: list[str], regenerated_patterns: list[str] ) -> list[OverrideContradiction]: """Detecte les cas ou un override manuel indique, pour EXACTEMENT la meme sequence de lettres qu'un motif du corpus, une decision de coupure de parite opposee. Limite assumee : seules les correspondances lettre-pour-lettre exactes sont comparees ; un chevauchement partiel (override sur une sous-chaine plus longue/courte) n'est pas detecte. Voir docs/methodology.md. """ corpus_by_letters: dict[str, list[int]] = {} for p in regenerated_patterns: letters, values = parse_liang_notation(p) corpus_by_letters[letters] = values contradictions = [] for override in manual_overrides: letters, override_values = parse_liang_notation(override) corpus_values = corpus_by_letters.get(letters) if corpus_values is None: continue for pos, (ov, cv) in enumerate(zip(override_values, corpus_values)): if ov == 0 or cv == 0: continue if (ov % 2) != (cv % 2): contradictions.append(OverrideContradiction(override, letters, pos, ov, cv)) return contradictions # --------------------------------------------------------------------------- # Chargement corpus / overrides # --------------------------------------------------------------------------- def load_annotated_words(path: Path) -> list[AnnotatedWord]: words = [] for line in path.read_text(encoding="utf-8").splitlines(): line = line.split("#", 1)[0].strip() if line: words.append(AnnotatedWord(raw=line)) return words def load_manual_overrides(path: Path) -> list[str]: if not path.exists(): return [] overrides = [] for line in path.read_text(encoding="utf-8").splitlines(): line = line.split("#", 1)[0].strip() if line: overrides.append(line) return overrides # --------------------------------------------------------------------------- # Export # --------------------------------------------------------------------------- def export_tex_patterns(patterns: list[str], output_path: Path) -> None: """Ecrit tex/hyph-mg.tex : motifs bruts uniquement (\\patterns{...}). NE PAS EDITER A LA MAIN -- voir source/manual-overrides.txt pour les correctifs ponctuels non encore integres au corpus.""" lines = [ "% Fichier genere automatiquement par source/generate_patterns.py", "% NE PAS EDITER A LA MAIN -- voir source/manual-overrides.txt", "\\patterns{%", ] lines += patterns lines.append("}") output_path.write_text("\n".join(lines), encoding="utf-8") def export_test_format_ini(patterns: list[str], output_path: Path) -> None: """Ecrit tex/hyphmg-test.ini : format INITEX autonome pour les tests pdfTeX (test/basic.tex, test/words.tex), avec \\newlanguage dynamique. Valide par execution reelle : le malgache DOIT etre isole sur son propre \\language (plain.tex precharge l'anglais sur \\language0 et \\patterns{} AJOUTE au lieu de remplacer).""" lines = [ "% Genere automatiquement -- format de TEST uniquement.", "% La production utilise \\addlanguage via l'integration hyph-utf8.", "\\catcode`\\@=11", "\\input plain", "\\newlanguage\\malagasylang", "\\language=\\malagasylang", "\\lefthyphenmin=2", "\\righthyphenmin=2", "\\patterns{%", ] lines += patterns lines += ["}", "\\patternsused=1", "\\dump"] output_path.write_text("\n".join(lines), encoding="utf-8") # --------------------------------------------------------------------------- # Point d'entree # --------------------------------------------------------------------------- def main() -> None: source = Path("source/malagasy-hyphenation.txt") overrides_path = Path("source/manual-overrides.txt") words = load_annotated_words(source) extractor = PatternExtractor(min_len=2, max_len=6) for w in words: extractor.add_word(w) phonotactics = MalagasyPhonotactics() rules = phonotactics.force_cluster_integrity(extractor.patterns()) overrides = load_manual_overrides(overrides_path) contradictions = find_override_contradictions(overrides, [r.pattern for r in rules]) for c in contradictions: print(f"[AVERTISSEMENT] override '{c.override_pattern}' contredit " f"le corpus '{c.corpus_pattern}' en position {c.position}") all_patterns = [r.pattern for r in rules] + overrides export_tex_patterns(all_patterns, Path("tex/hyph-mg.tex")) export_test_format_ini(all_patterns, Path("tex/hyphmg-test.ini")) print(f"{len(rules)} motif(s) du corpus + {len(overrides)} override(s) " f"-> tex/hyph-mg.tex + tex/hyphmg-test.ini") if __name__ == "__main__": main()