Skip to content

API Reference

This reference documents the public API of PyArud v1.0.0.

Arudh Processor

pyarud.processor.ArudhProcessor

Prosodic Engine for Arabic Poetry.

Performs phonetic Arudi transcription, metric pattern extraction, Bahr identification, foot-by-foot Zihaf diagnostic decomposition, and rhyme analysis.

Source code in pyarud/processor.py
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
class ArudhProcessor:
    """
    Prosodic Engine for Arabic Poetry.

    Performs phonetic Arudi transcription, metric pattern extraction,
    Bahr identification, foot-by-foot Zihaf diagnostic decomposition, and rhyme analysis.
    """

    def __init__(self, custom_replacements: dict[str, str] | None = None) -> None:
        self.converter = ArudiConverter(custom_replacements=custom_replacements)
        self.qafiyah_analyzer = QafiyahAnalyzer()
        self.engine = get_deterministic_engine()
        self.meter_classes = get_all_meters()
        (
            self.precomputed_patterns,
            self._sadr_exact_maps,
            self._ajuz_exact_maps,
        ) = _init_global_patterns()

    @staticmethod
    def _get_similarity(a: str, b: str) -> float:
        return _fast_similarity(a, b)

    def analyze_verse(
        self,
        sadr_text: str,
        ajuz_text: str | None = None,
        forced_meter: str | None = None,
        verse_index: int = 0,
    ) -> VerseAnalysis:
        """
        Analyzes a single poetic verse (Sadr and optional Ajuz).

        Args:
            sadr_text (str): First hemistich (الصدر).
            ajuz_text (str): Second hemistich (العجز).
            forced_meter (str, optional): Target meter key to force analysis against.
            verse_index (int): Index of the verse in the poem.

        Returns:
            VerseAnalysis: Strongly typed dataclass containing the complete prosodic breakdown.
        """
        ajuz_text = ajuz_text or ""
        # Generate candidates for Sadr (saturated and unsaturated)
        sadr_res_sat = self.converter.prepare_text(sadr_text, saturate=True)
        sadr_res_unsat = self.converter.prepare_text(sadr_text, saturate=False)

        # Generate candidates for Ajuz (saturated/Mutlaq and unsaturated/Muqayyad)
        ajuz_res_sat = self.converter.prepare_text(ajuz_text, saturate=True) if ajuz_text else ("", "")
        ajuz_res_unsat = (
            self.converter.prepare_text(ajuz_text, saturate=False, muqayyad=True) if ajuz_text else ("", "")
        )

        sadr_candidates = [sadr_res_sat]
        if sadr_res_unsat[1] != sadr_res_sat[1]:
            sadr_candidates.append(sadr_res_unsat)

        ajuz_candidates = [ajuz_res_sat]
        if ajuz_text and ajuz_res_unsat[1] != ajuz_res_sat[1]:
            ajuz_candidates.append(ajuz_res_unsat)

        # 1. Deterministic Formal Metric Grammar Matching
        det_matches = self.engine.match_verse(
            sadr_candidates, ajuz_candidates, target_meter=forced_meter
        )
        best_det = self.engine.disambiguate_exact_matches(
            det_matches,
            sadr_candidates[0][1],
            ajuz_candidates[0][1] if ajuz_candidates else "",
        )

        if best_det is not None:
            grammar, s_deriv, a_deriv, det_score, _is_valid_pair = best_det
            meter_key = grammar.meter_key
            meter_name_ar = grammar.name_ar
            meter_name_en = grammar.name_en
            bahr_type = grammar.bahr_type

            # Find matching candidate text/pattern
            chosen_sadr = sadr_candidates[0]
            if s_deriv:
                for cand in sadr_candidates:
                    if cand[1] == s_deriv.pattern:
                        chosen_sadr = cand
                        break

            chosen_ajuz = ajuz_candidates[0] if ajuz_candidates else ("", "")
            if a_deriv and ajuz_text:
                for cand in ajuz_candidates:
                    if cand[1] == a_deriv.pattern:
                        chosen_ajuz = cand
                        break

            sadr_feet = [
                FootAnalysis(
                    foot_index=i,
                    expected_pattern=fv.pattern,
                    actual_segment=fv.pattern,
                    base_tafeela=fv.base_tafeela,
                    actual_tafeela=fv.actual_tafeela,
                    zihaf_name_ar=fv.zihaf_name_ar,
                    zihaf_name_en=fv.zihaf_name_en,
                    score=1.0,
                    status="ok",
                )
                for i, fv in enumerate(s_deriv.feet)
            ] if s_deriv else []

            ajuz_feet = [
                FootAnalysis(
                    foot_index=i,
                    expected_pattern=fv.pattern,
                    actual_segment=fv.pattern,
                    base_tafeela=fv.base_tafeela,
                    actual_tafeela=fv.actual_tafeela,
                    zihaf_name_ar=fv.zihaf_name_ar,
                    zihaf_name_en=fv.zihaf_name_en,
                    score=1.0,
                    status="ok",
                )
                for i, fv in enumerate(a_deriv.feet)
            ] if (a_deriv and ajuz_text) else None

            sadr_analysis_obj = ShatrAnalysis(
                text=sadr_text,
                arudi_text=chosen_sadr[0],
                pattern=chosen_sadr[1],
                feet=sadr_feet,
                score=1.0,
                is_valid=True,
            )

            ajuz_analysis_obj = (
                ShatrAnalysis(
                    text=ajuz_text,
                    arudi_text=chosen_ajuz[0],
                    pattern=chosen_ajuz[1],
                    feet=ajuz_feet or [],
                    score=1.0,
                    is_valid=True,
                )
                if ajuz_text
                else None
            )

            # Rhyme (Qafiyah) analysis
            qafiyah_obj: QafiyahAnalysis | None = None
            if ajuz_text:
                qafiyah_obj = self.qafiyah_analyzer.analyze(
                    ajuz_text,
                    is_muqayyad=(chosen_ajuz[1].endswith("0") and chosen_ajuz == ajuz_res_unsat),
                )

            sadr_std = " ".join(f.pattern for f in s_deriv.feet) if s_deriv else ""
            ajuz_std = (" " + " ".join(f.pattern for f in a_deriv.feet)) if a_deriv else ""
            standard_pattern = (sadr_std + ajuz_std).strip()

            return VerseAnalysis(
                verse_index=verse_index,
                sadr_text=sadr_text,
                ajuz_text=ajuz_text,
                meter_key=meter_key,
                meter_name_ar=meter_name_ar,
                meter_name_en=meter_name_en,
                bahr_type=bahr_type,
                standard_pattern=standard_pattern,
                score=round(det_score, 3),
                sadr=sadr_analysis_obj,
                ajuz=ajuz_analysis_obj,
                qafiyah=qafiyah_obj,
                is_valid=True,
                errors=[],
            )

        # 2. Diagnostic Fallback when verse is broken or irregular
        candidates = self._find_best_meter(sadr_candidates, ajuz_candidates, target_meter=forced_meter)

        if not candidates:
            # Fallback when no meter could be detected
            s_shatr = ShatrAnalysis(
                text=sadr_text,
                arudi_text=sadr_res_sat[0],
                pattern=sadr_res_sat[1],
                score=0.0,
                is_valid=False,
            )
            a_shatr = (
                ShatrAnalysis(
                    text=ajuz_text,
                    arudi_text=ajuz_res_sat[0],
                    pattern=ajuz_res_sat[1],
                    score=0.0,
                    is_valid=False,
                )
                if ajuz_text
                else None
            )
            return VerseAnalysis(
                verse_index=verse_index,
                sadr_text=sadr_text,
                ajuz_text=ajuz_text,
                meter_key="unknown",
                meter_name_ar="بحر غير محدد",
                meter_name_en="Unknown Meter",
                bahr_type="unknown",
                standard_pattern="",
                score=0.0,
                sadr=s_shatr,
                ajuz=a_shatr,
                is_valid=False,
                errors=["Could not determine poetic meter."],
            )

        best_match = candidates[0]
        meter_key = best_match.meter_key
        meter_cls = self.meter_classes.get(meter_key)

        meter_name_ar = meter_cls.name_ar if meter_cls else meter_key
        meter_name_en = meter_cls.name_en if meter_cls else meter_key
        bahr_type = meter_cls.bahr_type if meter_cls else "tam"

        # Determine winning phonetic variations
        chosen_sadr = sadr_candidates[0]
        for cand in sadr_candidates:
            if cand[1] == best_match.sadr_input_pattern:
                chosen_sadr = cand
                break

        chosen_ajuz = ajuz_candidates[0]
        if ajuz_text:
            for cand in ajuz_candidates:
                if cand[1] == best_match.ajuz_input_pattern:
                    chosen_ajuz = cand
                    break

        patterns = self.precomputed_patterns.get(meter_key, {})
        sadr_comp = self._find_best_component_match(chosen_sadr[1], patterns.get("sadr", []))
        ajuz_comp = self._find_best_component_match(chosen_ajuz[1], patterns.get("ajuz", [])) if ajuz_text else None

        sadr_ref_feet = sadr_comp["ref"]["feet"] if sadr_comp.get("ref") else []
        ajuz_ref_feet = ajuz_comp["ref"]["feet"] if ajuz_comp and ajuz_comp.get("ref") else []

        sadr_feet = self._analyze_feet(chosen_sadr[1], sadr_ref_feet, sadr_comp.get("ref"))
        ajuz_feet = (
            self._analyze_feet(chosen_ajuz[1], ajuz_ref_feet, ajuz_comp.get("ref") if ajuz_comp else None)
            if ajuz_text
            else None
        )

        sadr_score = float(sadr_comp.get("score", 0.0))
        ajuz_score = float(ajuz_comp.get("score", 0.0)) if ajuz_comp else 1.0
        combined_score = (sadr_score + ajuz_score) / (2 if ajuz_text else 1)

        sadr_analysis_obj = ShatrAnalysis(
            text=sadr_text,
            arudi_text=chosen_sadr[0],
            pattern=chosen_sadr[1],
            feet=sadr_feet,
            score=sadr_score,
            is_valid=all(f.status == "ok" for f in sadr_feet),
        )

        ajuz_analysis_obj = (
            ShatrAnalysis(
                text=ajuz_text,
                arudi_text=chosen_ajuz[0],
                pattern=chosen_ajuz[1],
                feet=ajuz_feet or [],
                score=ajuz_score,
                is_valid=all(f.status == "ok" for f in (ajuz_feet or [])),
            )
            if ajuz_text
            else None
        )

        # Rhyme (Qafiyah) analysis
        qafiyah_obj = None
        if ajuz_text:
            qafiyah_obj = self.qafiyah_analyzer.analyze(
                ajuz_text, is_muqayyad=(chosen_ajuz[1].endswith("0") and chosen_ajuz == ajuz_res_unsat)
            )

        ref_sadr_pat = sadr_comp["ref"]["pattern"] if sadr_comp.get("ref") else ""
        ref_ajuz_pat = ajuz_comp["ref"]["pattern"] if ajuz_comp and ajuz_comp.get("ref") else ""
        standard_pattern = ref_sadr_pat + (" " + ref_ajuz_pat if ref_ajuz_pat else "")

        is_valid = (
            combined_score >= 0.85
            and sadr_analysis_obj.is_valid
            and (ajuz_analysis_obj is None or ajuz_analysis_obj.is_valid)
        )

        errors: list[str] = []
        if combined_score < 0.85:
            errors.append(f"Low metric similarity score: {combined_score:.2f}")
        for foot in sadr_feet:
            if foot.status != "ok":
                errors.append(
                    f"Sadr Foot {foot.foot_index + 1} is {foot.status}: "
                    f"expected {foot.expected_pattern}, got {foot.actual_segment}"
                )
        if ajuz_feet:
            for foot in ajuz_feet:
                if foot.status != "ok":
                    errors.append(
                        f"Ajuz Foot {foot.foot_index + 1} is {foot.status}: "
                        f"expected {foot.expected_pattern}, got {foot.actual_segment}"
                    )

        return VerseAnalysis(
            verse_index=verse_index,
            sadr_text=sadr_text,
            ajuz_text=ajuz_text,
            meter_key=meter_key,
            meter_name_ar=meter_name_ar,
            meter_name_en=meter_name_en,
            bahr_type=bahr_type,
            standard_pattern=standard_pattern,
            score=combined_score,
            sadr=sadr_analysis_obj,
            ajuz=ajuz_analysis_obj,
            qafiyah=qafiyah_obj,
            is_valid=is_valid,
            errors=errors,
        )

    def analyze_poem(
        self,
        verses: list[tuple[str, str]] | list[str],
        meter_name: str | None = None,
    ) -> PoemAnalysis:
        """
        Analyzes a collection of verses composing a complete poem.
        """
        normalized_verses: list[tuple[str, str]] = []
        for v in verses:
            if isinstance(v, tuple):
                normalized_verses.append(v)
            elif isinstance(v, list) and len(v) >= 2:
                normalized_verses.append((v[0], v[1]))
            elif isinstance(v, str):
                parts = v.split("...", 1) if "..." in v else v.split(" - ", 1)
                if len(parts) == 2:
                    normalized_verses.append((parts[0].strip(), parts[1].strip()))
                else:
                    normalized_verses.append((v.strip(), ""))

        detected_counts: Counter[str] = Counter()
        first_pass_analyses: list[VerseAnalysis] = []

        for i, (sadr, ajuz) in enumerate(normalized_verses):
            v_analysis = self.analyze_verse(sadr, ajuz, forced_meter=meter_name, verse_index=i)
            first_pass_analyses.append(v_analysis)
            if v_analysis.meter_key != "unknown":
                detected_counts[v_analysis.meter_key] += 1

        if meter_name:
            global_meter = meter_name
        elif detected_counts:
            global_meter = detected_counts.most_common(1)[0][0]
        else:
            global_meter = "unknown"

        # If global meter was determined by consensus and some verses differed, re-evaluate against global meter
        final_verses: list[VerseAnalysis] = []
        rawi_counter: Counter[str] = Counter()

        for i, (sadr, ajuz) in enumerate(normalized_verses):
            if meter_name or global_meter == "unknown" or first_pass_analyses[i].meter_key == global_meter:
                v_res = first_pass_analyses[i]
            else:
                v_res = self.analyze_verse(sadr, ajuz, forced_meter=global_meter, verse_index=i)

            final_verses.append(v_res)
            if v_res.qafiyah and v_res.qafiyah.rawi:
                rawi_counter[v_res.qafiyah.rawi] += 1

        meter_cls = self.meter_classes.get(global_meter)
        meter_name_ar = meter_cls.name_ar if meter_cls else "غير محدد"
        meter_name_en = meter_cls.name_en if meter_cls else "Unknown"
        bahr_type = meter_cls.bahr_type if meter_cls else "unknown"

        scores = [v.score for v in final_verses]
        avg_score = sum(scores) / len(scores) if scores else 0.0
        valid_count = sum(1 for v in final_verses if v.is_valid)
        is_homogeneous = len({v.meter_key for v in final_verses if v.meter_key != "unknown"}) <= 1
        dominant_rawi = rawi_counter.most_common(1)[0][0] if rawi_counter else None

        return PoemAnalysis(
            meter_key=global_meter,
            meter_name_ar=meter_name_ar,
            meter_name_en=meter_name_en,
            bahr_type=bahr_type,
            verses=final_verses,
            average_score=avg_score,
            is_homogeneous=is_homogeneous,
            dominant_rawi=dominant_rawi,
            total_verses=len(final_verses),
            valid_verses_count=valid_count,
        )

    def process_poem(
        self,
        verses: list[tuple[str, str]],
        meter_name: str | None = None,
    ) -> dict[str, Any]:
        """
        Backwards-compatible wrapper matching the legacy PyArud interface.
        """
        poem_analysis = self.analyze_poem(verses, meter_name=meter_name)
        if poem_analysis.meter_key == "unknown":
            return {"error": "Could not detect any valid meter."}

        # Format legacy dict
        legacy_verses: list[dict[str, Any]] = []
        for v in poem_analysis.verses:
            v_dict = v.to_dict()
            v_dict["sadr_analysis"] = [f.to_dict() for f in v.sadr.feet] if v.sadr is not None else None
            v_dict["ajuz_analysis"] = [f.to_dict() for f in v.ajuz.feet] if v.ajuz is not None else None
            legacy_verses.append(v_dict)

        return {
            "meter": poem_analysis.meter_key,
            "meter_name_ar": poem_analysis.meter_name_ar,
            "meter_name_en": poem_analysis.meter_name_en,
            "verses": legacy_verses,
        }

    def _find_best_meter(
        self,
        sadr_candidates: list[tuple[str, str]],
        ajuz_candidates: list[tuple[str, str]],
        target_meter: str | None = None,
    ) -> list[MeterMatchCandidate]:
        """Matches candidate patterns against all registered meters and scores them."""
        candidates: list[MeterMatchCandidate] = []

        meters_to_check: list[tuple[str, dict[str, Any]]] = list(self.precomputed_patterns.items())
        if target_meter:
            if target_meter in self.precomputed_patterns:
                meters_to_check = [(target_meter, self.precomputed_patterns[target_meter])]
            else:
                return []

        has_ajuz = any(c[1] for c in ajuz_candidates)

        for name, patterns in meters_to_check:
            s_exact = self._sadr_exact_maps.get(name, {})
            a_exact = self._ajuz_exact_maps.get(name, {})

            # 1. Score Sadr candidates
            best_sadr: dict[str, Any] | None = None
            best_sadr_score = -1.0
            best_sadr_input = ""

            for cand in sadr_candidates:
                cand_pat = cand[1]
                if cand_pat in s_exact:
                    match: dict[str, Any] = {"score": 1.0, "ref": s_exact[cand_pat]}
                else:
                    match = self._find_best_component_match(cand_pat, patterns["sadr"])

                score_val: float = float(match["score"])
                if score_val > best_sadr_score:
                    best_sadr_score = score_val
                    best_sadr = match
                    best_sadr_input = cand_pat
                    if best_sadr_score == 1.0:
                        break

            # 2. Score Ajuz candidates
            best_ajuz: dict[str, Any] | None = None
            best_ajuz_score = -1.0
            best_ajuz_input = ""

            if has_ajuz:
                for cand in ajuz_candidates:
                    cand_pat = cand[1]
                    if not cand_pat:
                        continue
                    if cand_pat in a_exact:
                        match = {"score": 1.0, "ref": a_exact[cand_pat]}
                    else:
                        match = self._find_best_component_match(cand_pat, patterns["ajuz"])

                    score_val = float(match["score"])
                    if score_val > best_ajuz_score:
                        best_ajuz_score = score_val
                        best_ajuz = match
                        best_ajuz_input = cand_pat
                        if best_ajuz_score == 1.0:
                            break

            s_score = best_sadr_score
            a_score = best_ajuz_score if best_ajuz else 0.0

            # Compatibility check for valid pair
            is_valid_pair = False
            if best_sadr and best_sadr["ref"] and (not has_ajuz or (best_ajuz and best_ajuz["ref"])):
                s_pat = best_sadr["ref"]["pattern"]
                a_pat = best_ajuz["ref"]["pattern"] if best_ajuz else ""
                if (s_pat, a_pat) in patterns["pairs"]:
                    is_valid_pair = True

            total_score = (s_score + a_score) / 2 if has_ajuz else s_score

            meter_cls = self.meter_classes.get(name)
            candidates.append(
                MeterMatchCandidate(
                    meter_key=name,
                    meter_name_ar=meter_cls.name_ar if meter_cls else name,
                    meter_name_en=meter_cls.name_en if meter_cls else name,
                    bahr_type=meter_cls.bahr_type if meter_cls else "tam",
                    score=total_score,
                    valid_pair=is_valid_pair,
                    sadr_match=best_sadr,
                    ajuz_match=best_ajuz,
                    sadr_input_pattern=best_sadr_input,
                    ajuz_input_pattern=best_ajuz_input,
                )
            )

        # Sort candidates: exact score first, then valid pair, then standard meter priority
        candidates.sort(
            key=lambda x: (
                round(x.score, 3),
                x.valid_pair,
                METER_PRIORITY.get(x.meter_key, 0),
            ),
            reverse=True,
        )

        return candidates

    def _find_best_component_match(
        self, input_pattern: str, component_patterns: list[dict[str, Any]]
    ) -> dict[str, Any]:
        """Finds closest matching reference pattern in a component list with length pruning."""
        if not input_pattern:
            return {"score": 0.0, "ref": None}

        # Fast exact check
        for item in component_patterns:
            if item["pattern"] == input_pattern:
                return {"score": 1.0, "ref": item}

        best_score = -1.0
        best_ref: dict[str, Any] | None = None
        input_len = len(input_pattern)

        for item in component_patterns:
            ref_pat = item["pattern"]
            # Prune candidates with incompatible length
            if abs(len(ref_pat) - input_len) > 4:
                continue
            score = self._get_similarity(ref_pat, input_pattern)
            if score > best_score:
                best_score = score
                best_ref = item
                if score == 1.0:
                    break

        if best_ref is None and component_patterns:
            best_ref = component_patterns[0]
            best_score = self._get_similarity(best_ref["pattern"], input_pattern)

        return {"score": best_score, "ref": best_ref}

    def _analyze_feet(
        self,
        input_pattern: str,
        ref_feet: list[str],
        best_ref: dict[str, Any] | None,
    ) -> list[FootAnalysis]:
        """
        Decomposes the binary metric pattern into feet using reference foot sequence,
        mapping each segment to its specific Zihaf/'Ilal variation and Arabic names.
        """
        analysis: list[FootAnalysis] = []
        current_idx = 0
        num_feet = len(ref_feet)

        for i in range(num_feet):
            expected_pat = ref_feet[i]
            cand_len = len(expected_pat)
            end_idx = min(current_idx + cand_len, len(input_pattern))
            actual_segment = input_pattern[current_idx:end_idx]

            if not actual_segment:
                analysis.append(
                    FootAnalysis(
                        foot_index=i,
                        expected_pattern=expected_pat,
                        actual_segment="MISSING",
                        base_tafeela="",
                        actual_tafeela="",
                        zihaf_name_ar="مفقودة",
                        zihaf_name_en="Missing",
                        score=0.0,
                        status="missing",
                    )
                )
                continue

            final_score = self._get_similarity(expected_pat, actual_segment)
            status = "ok" if final_score == 1.0 else "broken"

            # Look up Tafeela and Zihaf information
            tafeela_info = TAFEELA_VARIATION_MAP.get((expected_pat, actual_segment)) or DEFAULT_PATTERN_TAFEELA.get(
                expected_pat
            )

            if tafeela_info:
                base_t, act_t, z_ar, z_en = tafeela_info
            else:
                base_t, act_t, z_ar, z_en = "", "", "سالمة", "Salim"

            analysis.append(
                FootAnalysis(
                    foot_index=i,
                    expected_pattern=expected_pat,
                    actual_segment=actual_segment,
                    base_tafeela=base_t,
                    actual_tafeela=act_t,
                    zihaf_name_ar=z_ar,
                    zihaf_name_en=z_en,
                    score=final_score,
                    status=status,
                )
            )

            current_idx = end_idx

        # Extra bits at end of hemistich
        if current_idx < len(input_pattern):
            extra = input_pattern[current_idx:]
            analysis.append(
                FootAnalysis(
                    foot_index=num_feet,
                    expected_pattern="",
                    actual_segment=extra,
                    base_tafeela="",
                    actual_tafeela="",
                    zihaf_name_ar="زيادة غير مطابقة",
                    zihaf_name_en="Extra Bits",
                    score=0.0,
                    status="extra_bits",
                )
            )

        return analysis

analyze_verse(sadr_text, ajuz_text=None, forced_meter=None, verse_index=0)

Analyzes a single poetic verse (Sadr and optional Ajuz).

Parameters:

Name Type Description Default
sadr_text str

First hemistich (الصدر).

required
ajuz_text str

Second hemistich (العجز).

None
forced_meter str

Target meter key to force analysis against.

None
verse_index int

Index of the verse in the poem.

0

Returns:

Name Type Description
VerseAnalysis VerseAnalysis

Strongly typed dataclass containing the complete prosodic breakdown.

Source code in pyarud/processor.py
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
def analyze_verse(
    self,
    sadr_text: str,
    ajuz_text: str | None = None,
    forced_meter: str | None = None,
    verse_index: int = 0,
) -> VerseAnalysis:
    """
    Analyzes a single poetic verse (Sadr and optional Ajuz).

    Args:
        sadr_text (str): First hemistich (الصدر).
        ajuz_text (str): Second hemistich (العجز).
        forced_meter (str, optional): Target meter key to force analysis against.
        verse_index (int): Index of the verse in the poem.

    Returns:
        VerseAnalysis: Strongly typed dataclass containing the complete prosodic breakdown.
    """
    ajuz_text = ajuz_text or ""
    # Generate candidates for Sadr (saturated and unsaturated)
    sadr_res_sat = self.converter.prepare_text(sadr_text, saturate=True)
    sadr_res_unsat = self.converter.prepare_text(sadr_text, saturate=False)

    # Generate candidates for Ajuz (saturated/Mutlaq and unsaturated/Muqayyad)
    ajuz_res_sat = self.converter.prepare_text(ajuz_text, saturate=True) if ajuz_text else ("", "")
    ajuz_res_unsat = (
        self.converter.prepare_text(ajuz_text, saturate=False, muqayyad=True) if ajuz_text else ("", "")
    )

    sadr_candidates = [sadr_res_sat]
    if sadr_res_unsat[1] != sadr_res_sat[1]:
        sadr_candidates.append(sadr_res_unsat)

    ajuz_candidates = [ajuz_res_sat]
    if ajuz_text and ajuz_res_unsat[1] != ajuz_res_sat[1]:
        ajuz_candidates.append(ajuz_res_unsat)

    # 1. Deterministic Formal Metric Grammar Matching
    det_matches = self.engine.match_verse(
        sadr_candidates, ajuz_candidates, target_meter=forced_meter
    )
    best_det = self.engine.disambiguate_exact_matches(
        det_matches,
        sadr_candidates[0][1],
        ajuz_candidates[0][1] if ajuz_candidates else "",
    )

    if best_det is not None:
        grammar, s_deriv, a_deriv, det_score, _is_valid_pair = best_det
        meter_key = grammar.meter_key
        meter_name_ar = grammar.name_ar
        meter_name_en = grammar.name_en
        bahr_type = grammar.bahr_type

        # Find matching candidate text/pattern
        chosen_sadr = sadr_candidates[0]
        if s_deriv:
            for cand in sadr_candidates:
                if cand[1] == s_deriv.pattern:
                    chosen_sadr = cand
                    break

        chosen_ajuz = ajuz_candidates[0] if ajuz_candidates else ("", "")
        if a_deriv and ajuz_text:
            for cand in ajuz_candidates:
                if cand[1] == a_deriv.pattern:
                    chosen_ajuz = cand
                    break

        sadr_feet = [
            FootAnalysis(
                foot_index=i,
                expected_pattern=fv.pattern,
                actual_segment=fv.pattern,
                base_tafeela=fv.base_tafeela,
                actual_tafeela=fv.actual_tafeela,
                zihaf_name_ar=fv.zihaf_name_ar,
                zihaf_name_en=fv.zihaf_name_en,
                score=1.0,
                status="ok",
            )
            for i, fv in enumerate(s_deriv.feet)
        ] if s_deriv else []

        ajuz_feet = [
            FootAnalysis(
                foot_index=i,
                expected_pattern=fv.pattern,
                actual_segment=fv.pattern,
                base_tafeela=fv.base_tafeela,
                actual_tafeela=fv.actual_tafeela,
                zihaf_name_ar=fv.zihaf_name_ar,
                zihaf_name_en=fv.zihaf_name_en,
                score=1.0,
                status="ok",
            )
            for i, fv in enumerate(a_deriv.feet)
        ] if (a_deriv and ajuz_text) else None

        sadr_analysis_obj = ShatrAnalysis(
            text=sadr_text,
            arudi_text=chosen_sadr[0],
            pattern=chosen_sadr[1],
            feet=sadr_feet,
            score=1.0,
            is_valid=True,
        )

        ajuz_analysis_obj = (
            ShatrAnalysis(
                text=ajuz_text,
                arudi_text=chosen_ajuz[0],
                pattern=chosen_ajuz[1],
                feet=ajuz_feet or [],
                score=1.0,
                is_valid=True,
            )
            if ajuz_text
            else None
        )

        # Rhyme (Qafiyah) analysis
        qafiyah_obj: QafiyahAnalysis | None = None
        if ajuz_text:
            qafiyah_obj = self.qafiyah_analyzer.analyze(
                ajuz_text,
                is_muqayyad=(chosen_ajuz[1].endswith("0") and chosen_ajuz == ajuz_res_unsat),
            )

        sadr_std = " ".join(f.pattern for f in s_deriv.feet) if s_deriv else ""
        ajuz_std = (" " + " ".join(f.pattern for f in a_deriv.feet)) if a_deriv else ""
        standard_pattern = (sadr_std + ajuz_std).strip()

        return VerseAnalysis(
            verse_index=verse_index,
            sadr_text=sadr_text,
            ajuz_text=ajuz_text,
            meter_key=meter_key,
            meter_name_ar=meter_name_ar,
            meter_name_en=meter_name_en,
            bahr_type=bahr_type,
            standard_pattern=standard_pattern,
            score=round(det_score, 3),
            sadr=sadr_analysis_obj,
            ajuz=ajuz_analysis_obj,
            qafiyah=qafiyah_obj,
            is_valid=True,
            errors=[],
        )

    # 2. Diagnostic Fallback when verse is broken or irregular
    candidates = self._find_best_meter(sadr_candidates, ajuz_candidates, target_meter=forced_meter)

    if not candidates:
        # Fallback when no meter could be detected
        s_shatr = ShatrAnalysis(
            text=sadr_text,
            arudi_text=sadr_res_sat[0],
            pattern=sadr_res_sat[1],
            score=0.0,
            is_valid=False,
        )
        a_shatr = (
            ShatrAnalysis(
                text=ajuz_text,
                arudi_text=ajuz_res_sat[0],
                pattern=ajuz_res_sat[1],
                score=0.0,
                is_valid=False,
            )
            if ajuz_text
            else None
        )
        return VerseAnalysis(
            verse_index=verse_index,
            sadr_text=sadr_text,
            ajuz_text=ajuz_text,
            meter_key="unknown",
            meter_name_ar="بحر غير محدد",
            meter_name_en="Unknown Meter",
            bahr_type="unknown",
            standard_pattern="",
            score=0.0,
            sadr=s_shatr,
            ajuz=a_shatr,
            is_valid=False,
            errors=["Could not determine poetic meter."],
        )

    best_match = candidates[0]
    meter_key = best_match.meter_key
    meter_cls = self.meter_classes.get(meter_key)

    meter_name_ar = meter_cls.name_ar if meter_cls else meter_key
    meter_name_en = meter_cls.name_en if meter_cls else meter_key
    bahr_type = meter_cls.bahr_type if meter_cls else "tam"

    # Determine winning phonetic variations
    chosen_sadr = sadr_candidates[0]
    for cand in sadr_candidates:
        if cand[1] == best_match.sadr_input_pattern:
            chosen_sadr = cand
            break

    chosen_ajuz = ajuz_candidates[0]
    if ajuz_text:
        for cand in ajuz_candidates:
            if cand[1] == best_match.ajuz_input_pattern:
                chosen_ajuz = cand
                break

    patterns = self.precomputed_patterns.get(meter_key, {})
    sadr_comp = self._find_best_component_match(chosen_sadr[1], patterns.get("sadr", []))
    ajuz_comp = self._find_best_component_match(chosen_ajuz[1], patterns.get("ajuz", [])) if ajuz_text else None

    sadr_ref_feet = sadr_comp["ref"]["feet"] if sadr_comp.get("ref") else []
    ajuz_ref_feet = ajuz_comp["ref"]["feet"] if ajuz_comp and ajuz_comp.get("ref") else []

    sadr_feet = self._analyze_feet(chosen_sadr[1], sadr_ref_feet, sadr_comp.get("ref"))
    ajuz_feet = (
        self._analyze_feet(chosen_ajuz[1], ajuz_ref_feet, ajuz_comp.get("ref") if ajuz_comp else None)
        if ajuz_text
        else None
    )

    sadr_score = float(sadr_comp.get("score", 0.0))
    ajuz_score = float(ajuz_comp.get("score", 0.0)) if ajuz_comp else 1.0
    combined_score = (sadr_score + ajuz_score) / (2 if ajuz_text else 1)

    sadr_analysis_obj = ShatrAnalysis(
        text=sadr_text,
        arudi_text=chosen_sadr[0],
        pattern=chosen_sadr[1],
        feet=sadr_feet,
        score=sadr_score,
        is_valid=all(f.status == "ok" for f in sadr_feet),
    )

    ajuz_analysis_obj = (
        ShatrAnalysis(
            text=ajuz_text,
            arudi_text=chosen_ajuz[0],
            pattern=chosen_ajuz[1],
            feet=ajuz_feet or [],
            score=ajuz_score,
            is_valid=all(f.status == "ok" for f in (ajuz_feet or [])),
        )
        if ajuz_text
        else None
    )

    # Rhyme (Qafiyah) analysis
    qafiyah_obj = None
    if ajuz_text:
        qafiyah_obj = self.qafiyah_analyzer.analyze(
            ajuz_text, is_muqayyad=(chosen_ajuz[1].endswith("0") and chosen_ajuz == ajuz_res_unsat)
        )

    ref_sadr_pat = sadr_comp["ref"]["pattern"] if sadr_comp.get("ref") else ""
    ref_ajuz_pat = ajuz_comp["ref"]["pattern"] if ajuz_comp and ajuz_comp.get("ref") else ""
    standard_pattern = ref_sadr_pat + (" " + ref_ajuz_pat if ref_ajuz_pat else "")

    is_valid = (
        combined_score >= 0.85
        and sadr_analysis_obj.is_valid
        and (ajuz_analysis_obj is None or ajuz_analysis_obj.is_valid)
    )

    errors: list[str] = []
    if combined_score < 0.85:
        errors.append(f"Low metric similarity score: {combined_score:.2f}")
    for foot in sadr_feet:
        if foot.status != "ok":
            errors.append(
                f"Sadr Foot {foot.foot_index + 1} is {foot.status}: "
                f"expected {foot.expected_pattern}, got {foot.actual_segment}"
            )
    if ajuz_feet:
        for foot in ajuz_feet:
            if foot.status != "ok":
                errors.append(
                    f"Ajuz Foot {foot.foot_index + 1} is {foot.status}: "
                    f"expected {foot.expected_pattern}, got {foot.actual_segment}"
                )

    return VerseAnalysis(
        verse_index=verse_index,
        sadr_text=sadr_text,
        ajuz_text=ajuz_text,
        meter_key=meter_key,
        meter_name_ar=meter_name_ar,
        meter_name_en=meter_name_en,
        bahr_type=bahr_type,
        standard_pattern=standard_pattern,
        score=combined_score,
        sadr=sadr_analysis_obj,
        ajuz=ajuz_analysis_obj,
        qafiyah=qafiyah_obj,
        is_valid=is_valid,
        errors=errors,
    )

analyze_poem(verses, meter_name=None)

Analyzes a collection of verses composing a complete poem.

Source code in pyarud/processor.py
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
def analyze_poem(
    self,
    verses: list[tuple[str, str]] | list[str],
    meter_name: str | None = None,
) -> PoemAnalysis:
    """
    Analyzes a collection of verses composing a complete poem.
    """
    normalized_verses: list[tuple[str, str]] = []
    for v in verses:
        if isinstance(v, tuple):
            normalized_verses.append(v)
        elif isinstance(v, list) and len(v) >= 2:
            normalized_verses.append((v[0], v[1]))
        elif isinstance(v, str):
            parts = v.split("...", 1) if "..." in v else v.split(" - ", 1)
            if len(parts) == 2:
                normalized_verses.append((parts[0].strip(), parts[1].strip()))
            else:
                normalized_verses.append((v.strip(), ""))

    detected_counts: Counter[str] = Counter()
    first_pass_analyses: list[VerseAnalysis] = []

    for i, (sadr, ajuz) in enumerate(normalized_verses):
        v_analysis = self.analyze_verse(sadr, ajuz, forced_meter=meter_name, verse_index=i)
        first_pass_analyses.append(v_analysis)
        if v_analysis.meter_key != "unknown":
            detected_counts[v_analysis.meter_key] += 1

    if meter_name:
        global_meter = meter_name
    elif detected_counts:
        global_meter = detected_counts.most_common(1)[0][0]
    else:
        global_meter = "unknown"

    # If global meter was determined by consensus and some verses differed, re-evaluate against global meter
    final_verses: list[VerseAnalysis] = []
    rawi_counter: Counter[str] = Counter()

    for i, (sadr, ajuz) in enumerate(normalized_verses):
        if meter_name or global_meter == "unknown" or first_pass_analyses[i].meter_key == global_meter:
            v_res = first_pass_analyses[i]
        else:
            v_res = self.analyze_verse(sadr, ajuz, forced_meter=global_meter, verse_index=i)

        final_verses.append(v_res)
        if v_res.qafiyah and v_res.qafiyah.rawi:
            rawi_counter[v_res.qafiyah.rawi] += 1

    meter_cls = self.meter_classes.get(global_meter)
    meter_name_ar = meter_cls.name_ar if meter_cls else "غير محدد"
    meter_name_en = meter_cls.name_en if meter_cls else "Unknown"
    bahr_type = meter_cls.bahr_type if meter_cls else "unknown"

    scores = [v.score for v in final_verses]
    avg_score = sum(scores) / len(scores) if scores else 0.0
    valid_count = sum(1 for v in final_verses if v.is_valid)
    is_homogeneous = len({v.meter_key for v in final_verses if v.meter_key != "unknown"}) <= 1
    dominant_rawi = rawi_counter.most_common(1)[0][0] if rawi_counter else None

    return PoemAnalysis(
        meter_key=global_meter,
        meter_name_ar=meter_name_ar,
        meter_name_en=meter_name_en,
        bahr_type=bahr_type,
        verses=final_verses,
        average_score=avg_score,
        is_homogeneous=is_homogeneous,
        dominant_rawi=dominant_rawi,
        total_verses=len(final_verses),
        valid_verses_count=valid_count,
    )

process_poem(verses, meter_name=None)

Backwards-compatible wrapper matching the legacy PyArud interface.

Source code in pyarud/processor.py
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
def process_poem(
    self,
    verses: list[tuple[str, str]],
    meter_name: str | None = None,
) -> dict[str, Any]:
    """
    Backwards-compatible wrapper matching the legacy PyArud interface.
    """
    poem_analysis = self.analyze_poem(verses, meter_name=meter_name)
    if poem_analysis.meter_key == "unknown":
        return {"error": "Could not detect any valid meter."}

    # Format legacy dict
    legacy_verses: list[dict[str, Any]] = []
    for v in poem_analysis.verses:
        v_dict = v.to_dict()
        v_dict["sadr_analysis"] = [f.to_dict() for f in v.sadr.feet] if v.sadr is not None else None
        v_dict["ajuz_analysis"] = [f.to_dict() for f in v.ajuz.feet] if v.ajuz is not None else None
        legacy_verses.append(v_dict)

    return {
        "meter": poem_analysis.meter_key,
        "meter_name_ar": poem_analysis.meter_name_ar,
        "meter_name_en": poem_analysis.meter_name_en,
        "verses": legacy_verses,
    }

Formatter Utilities

pyarud.formatters.console

Console and ASCII formatting utilities for prosodic analysis.

format_verse_report(verse)

Formats a single verse analysis into a structured summary report.

Source code in pyarud/formatters/console.py
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
def format_verse_report(verse: VerseAnalysis) -> str:
    """Formats a single verse analysis into a structured summary report."""
    lines: list[str] = []
    lines.append(f"═══ [البيت {verse.verse_index + 1}] {verse.meter_name_ar} ({verse.meter_name_en}) ═══")
    lines.append(f"• الصدر: {verse.sadr_text}")
    if verse.ajuz_text:
        lines.append(f"• العجز: {verse.ajuz_text}")
    lines.append(f"• النمط العروضي: {verse.standard_pattern}")
    status_str = "صحيح موزون" if verse.is_valid else "مكسور أو به خلل"
    lines.append(f"• درجة التوافق: {verse.score * 100:.1f}% | الحالة: {status_str}")

    # Sadr breakdown
    if verse.sadr:
        lines.append("\n  [تقطيع الصدر]")
        for foot in verse.sadr.feet:
            status_sym = "✓" if foot.status == "ok" else "✗"
            lines.append(
                f"    {status_sym} التفعيلة {foot.foot_index + 1}: {foot.actual_tafeela or foot.base_tafeela} "
                f"({foot.actual_segment}) - {foot.zihaf_name_ar}"
            )

    # Ajuz breakdown
    if verse.ajuz:
        lines.append("\n  [تقطيع العجز]")
        for foot in verse.ajuz.feet:
            status_sym = "✓" if foot.status == "ok" else "✗"
            lines.append(
                f"    {status_sym} التفعيلة {foot.foot_index + 1}: {foot.actual_tafeela or foot.base_tafeela} "
                f"({foot.actual_segment}) - {foot.zihaf_name_ar}"
            )

    # Qafiyah
    if verse.qafiyah and verse.qafiyah.rawi:
        lines.append("\n  [علم القافية]")
        lines.append(f"    • الروي: {verse.qafiyah.rawi} ({verse.qafiyah.rawi_haraka or 'ساكن'})")
        if verse.qafiyah.wasl:
            lines.append(f"    • الوصل: {verse.qafiyah.wasl}")
        if verse.qafiyah.ridf:
            lines.append(f"    • الردف: {verse.qafiyah.ridf}")
        if verse.qafiyah.tasees:
            lines.append(f"    • التأسيس: {verse.qafiyah.tasees} (الدخيل: {verse.qafiyah.dakhil})")
        lines.append(f"    • نوع القافية: {verse.qafiyah.qafiyah_type_ar} ({verse.qafiyah.rhyme_classification})")
        lines.append(f"    • مقطع القافية: {verse.qafiyah.qafiyah_text} [{verse.qafiyah.qafiyah_pattern}]")

    return "\n".join(lines)

format_poem_report(poem)

Formats an entire poem analysis into an executive prosodic report.

Source code in pyarud/formatters/console.py
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
def format_poem_report(poem: PoemAnalysis) -> str:
    """Formats an entire poem analysis into an executive prosodic report."""
    lines: list[str] = [
        "╔══════════════════════════════════════════════════════════════╗",
        f"║  تقرير التحليل العروضي الشامل: {poem.meter_name_ar:<28} ║",
        "╚══════════════════════════════════════════════════════════════╝",
        f"• البحر المكتشف: {poem.meter_name_ar} ({poem.meter_name_en}) - نوع البحر: {poem.bahr_type}",
        f"• عدد الأبيات: {poem.total_verses} | الأبيات السليمة: {poem.valid_verses_count}",
        f"• متوسط التوافق العروضي: {poem.average_score * 100:.1f}%",
        f"• وحدة البحر: {'تام ومتجانس' if poem.is_homogeneous else 'متفاوت / متعدد البحور'}",
    ]

    if poem.dominant_rawi:
        lines.append(f"• حرف الروي السائد: {poem.dominant_rawi}")

    lines.append("\n" + "─" * 64 + "\n")

    for v in poem.verses:
        lines.append(format_verse_report(v))
        lines.append("")

    return "\n".join(lines)

Arudi Converter (Phonetics Engine)

pyarud.core.phonetics.ArudiConverter

Phonetic Converter for Arabic Prosody (Ilm al-Arud).

Converts standard diacritized Arabic poetry into phonetic Arudi writing and extracts binary patterns (1 = Mutaharrik, 0 = Sakin).

Source code in pyarud/core/phonetics.py
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
class ArudiConverter:
    """
    Phonetic Converter for Arabic Prosody (*Ilm al-Arud*).

    Converts standard diacritized Arabic poetry into phonetic Arudi writing
    and extracts binary patterns (1 = Mutaharrik, 0 = Sakin).
    """

    def __init__(self, custom_replacements: dict[str, str] | None = None) -> None:
        self.replacements = dict(DEFAULT_ARUDI_REPLACEMENTS)
        if custom_replacements:
            self.replacements.update(custom_replacements)

        self.harakat = HARAKAT
        self.sukun = (SUKUN,)
        self.mostly_saken = LONG_VOWELS
        self.tnween_chars = TANWEEN
        self.shadda_chars = (SHADDA,)
        self.all_chars = list(LETTERS + " ")
        self.prem_chars = set(
            self.harakat
            + self.sukun
            + self.mostly_saken
            + self.tnween_chars
            + self.shadda_chars
            + tuple(self.all_chars)
        )

    def register_custom_spelling(self, word: str, replacement: str) -> None:
        """Register a custom phonetic spelling for a specific unvocalized word."""
        self.replacements[word] = replacement

    def _normalize_shadda(self, text: str) -> str:
        """Ensure Shadda precedes short vowels or tanween."""
        harakat_all = "".join(HARAKAT + TANWEEN)
        return re.sub(f"([{harakat_all}])([{SHADDA}])", r"\2\1", text)

    def _clean_extra_harakat(self, text: str) -> str:
        """Collapse consecutive vowel marks to a single mark."""
        return _RE_DOUBLE_HARAKA.sub(r"\1", text)

    def _resolve_wasl(self, text: str) -> str:
        """
        Handles Hamzat al-Wasl (همزة الوصل) and Iltiqa al-Sakinayn (التقاء الساكنين).
        1. Drop preceding long vowel + space + Wasl Alif (e.g. 'في البيت' -> 'فِلْبَيْتِ').
        2. Drop space + Wasl Alif in connected speech.
        3. Drop Alif in 'Allah' when prefixed by prepositions/particles.
        """
        # Long vowel before Wasl: 'فِي البَيْتِ' -> 'فِالبَيْتِ' -> 'فِلْبَيْتِ'
        text = _RE_LONG_VOWEL_WASL.sub(r"\1", text)

        # Space + Wasl: drop both
        text = _RE_SPACE_WASL.sub("", text)

        # Prefix + Allah: 'فَالله' -> 'فَلله'
        text = _RE_ALLAH_PREFIX.sub(r"\1\2\3", text)

        return text

    def _process_specials_before(self, bait: str) -> str:
        """Handle pre-phonetic orthographic and grammatical replacements."""
        # Initial bare Alif -> hamza with fatha for prosody
        if bait and bait[0] == ALEF:
            bait = ALEF_HAMZA_ABOVE + FATHA + bait[1:]

        # Detach prefixes before 'Al-' (e.g., 'والبيت' -> 'وَ ال بيت')
        bait = _RE_DETACH_AL.sub(r"\1\2\3 ال", bait)

        # Solar Lam Handling: ' ال شمس' -> ' ا شمس' (Lam is assimilated)
        bait = _RE_SOLAR_LAM.sub(r" ا\1", bait)

        # Waw of plural: 'قالوا ' -> 'قالو '
        bait = bait.replace("وا ", "و ")
        if bait.endswith("وا"):
            bait = bait[:-1]
        bait = bait.replace("وْا", "و")
        if bait.endswith("وْا"):
            bait = bait[:-2] + "و"

        # Common phrases & contractions
        bait = bait.replace("الله", "اللاه")
        bait = bait.replace("اللّه", "اللاه")
        bait = bait.replace("إلَّا", "إِلَّا")
        bait = bait.replace("نْ ال", "نَ ال")
        bait = bait.replace("لْ ال", "لِ ال")
        bait = bait.replace("ْ ال", "ِ ال")
        bait = bait.replace("عَمْرٍو", "عَمْرٍ")
        bait = bait.replace("عَمْرُو", "عَمْرُ")
        bait = bait.replace("عَمْرٌو", "عَمْرٌ")

        # Replace irregular words using dictionary
        words = bait.split(" ")
        out: list[str] = []

        removable_chars = "".join(HARAKAT + TANWEEN + (SUKUN,))
        strip_harakat_pattern = f"[{removable_chars}]"

        for word in words:
            if not word:
                continue

            cleaned_with_shadda = re.sub(strip_harakat_pattern, "", word)
            cleaned_plain = strip_tashkeel(word)

            found = False
            for candidate in (cleaned_with_shadda, cleaned_plain):
                if candidate in self.replacements:
                    out.append(self.replacements[candidate])
                    found = True
                    break

            if found:
                continue

            # Check prefixes
            for candidate in (cleaned_with_shadda, cleaned_plain):
                if found:
                    break
                for key, replacement in self.replacements.items():
                    if candidate.endswith(key) and len(candidate) > len(key):
                        prefix = candidate[: -len(key)]
                        if prefix in VALID_PREFIXES:
                            new_prefix = "".join(PREFIX_HARAKAT.get(p, p) for p in prefix)
                            out.append(new_prefix + replacement)
                            found = True
                            break

            if not found:
                out.append(word)

        bait = " ".join(out)

        # If second char is bare consonant when first is consonant, assume default vowel
        if (
            len(bait) > 1
            and bait[0] in LETTERS_SET
            and bait[1] in LETTERS_SET
            and bait[1] != " "
            and bait[1] not in (ALEF, WAW, YEH, ALEF_MAKSURA)
        ):
            bait = bait[0] + FATHA + bait[1:]

        # Filter trailing Alif of Tanween Fath (e.g. 'كِتَاباً' -> 'كِتَابَنْ')
        final_chars: list[str] = []
        i = 0
        while i < len(bait):
            if bait[i] == ALEF and i > 0 and bait[i - 1] in TANWEEN_SET:
                i += 1
                while i < len(bait) and bait[i] in self.prem_chars and bait[i] not in LETTERS_SET:
                    i += 1
                continue
            final_chars.append(bait[i])
            i += 1

        return "".join(final_chars)

    def _extract_pattern(self, text: str, saturate: bool = True, muqayyad: bool = False) -> tuple[str, str]:
        """
        Extract the Arudi phonetic text and binary prosodic pattern (1s and 0s).
        """
        text = self._clean_extra_harakat(text)
        # Expand Madda (آ -> ءَا)
        text = text.replace(ALEF_MADDA, "ءَ" + ALEF)
        chars = [c for c in text if c in self.prem_chars]
        chars = list(_RE_MULTI_SPACES.sub(" ", "".join(chars)).strip())

        out_pattern: list[str] = []
        plain_chars: list[str] = []

        i = 0
        n = len(chars)

        while i < n:
            char = chars[i]
            next_char = chars[i + 1] if i + 1 < n else ""
            next_next_char = chars[i + 2] if i + 2 < n else ""
            prev_digit = out_pattern[-1] if out_pattern else ""

            if char == " ":
                plain_chars.append(" ")
                i += 1
                continue

            if char in (ALEF, ALEF_MAKSURA):
                if prev_digit != "0":
                    out_pattern.append("0")
                plain_chars.append(char)
                i += 1
                continue

            if char in LETTERS_SET:
                # Look ahead past spaces
                if next_char == " " and next_next_char:
                    next_char = next_next_char

                if next_char in HARAKAT_SET:
                    is_last_group = i + 2 >= n
                    if muqayyad and is_last_group:
                        out_pattern.append("0")
                        plain_chars.append(char)
                    else:
                        out_pattern.append("1")
                        plain_chars.append(char)

                elif next_char in self.sukun:
                    if prev_digit != "0" or (i + 1) == n - 1:
                        out_pattern.append("0")
                        plain_chars.append(char)
                    else:
                        if plain_chars and plain_chars[-1] == " ":
                            plain_chars.pop()
                        plain_chars.append(char)

                elif next_char in TANWEEN_SET:
                    if char != ALEF:
                        plain_chars.append(char)
                    plain_chars.append(NOON)
                    out_pattern.extend(["1", "0"])

                    # Skip trailing alif after tanween fath
                    if i + 2 < n and chars[i + 2] == ALEF:
                        i += 1

                elif next_char in self.shadda_chars:
                    if prev_digit != "0":
                        plain_chars.extend([char, char])
                        out_pattern.extend(["0", "1"])
                    else:
                        if plain_chars and plain_chars[-1] == " ":
                            plain_chars.pop()
                        plain_chars.extend([char, char])
                        out_pattern.append("1")

                    # Check what follows Shadda
                    if i + 2 < n:
                        if chars[i + 2] in HARAKAT_SET:
                            is_last_shadda = i + 3 >= n
                            if muqayyad and is_last_shadda:
                                out_pattern[-1] = "0"
                            i += 1  # consume haraka
                        elif chars[i + 2] in TANWEEN_SET:
                            i += 1
                            plain_chars.append(NOON)
                            out_pattern.append("0")
                            if i + 2 < n and chars[i + 2] == ALEF:
                                i += 1

                elif next_char in (ALEF, ALEF_MAKSURA):
                    out_pattern.extend(["1", "0"])
                    plain_chars.extend([char, next_char])

                elif next_char in LETTERS_SET:
                    if prev_digit != "0":
                        out_pattern.append("0")
                        plain_chars.append(char)
                    elif prev_digit == "0" and i + 1 < n and chars[i + 1] == " ":
                        out_pattern.append("1")
                        plain_chars.append(char)
                    else:
                        if plain_chars and plain_chars[-1] == " ":
                            plain_chars.pop()
                        plain_chars.append(char)
                        out_pattern.append("0")
                    i -= 1
                else:
                    # End of text without explicit haraka/sukun
                    if prev_digit != "0":
                        out_pattern.append("0")
                    else:
                        out_pattern.append("1")
                    plain_chars.append(char)
                    i += 1
                    continue

                # Pronoun Ha saturation (هاء الضمير / هاء الغائب):
                # In Arabic prosody, Haa al-Dhamir saturates only when preceded by a mutaharrik (vocalized) consonant
                if not muqayyad and next_next_char == " " and len(out_pattern) >= 2 and out_pattern[-2] == "1":
                    if char == "ه":
                        if next_char == KASRA:
                            plain_chars.append(YEH)
                            out_pattern.append("0")
                        elif next_char == DAMMA:
                            plain_chars.append(WAW)
                            out_pattern.append("0")

                i += 2
            else:
                i += 1

        pattern_str = "".join(out_pattern)
        arudi_str = "".join(plain_chars)

        # Final saturation of Mutlaq rhyme
        if not muqayyad and saturate and pattern_str and pattern_str[-1] != "0":
            pattern_str += "0"

        if not muqayyad and saturate and chars:
            last_char = chars[-1]
            if last_char == KASRA:
                arudi_str += YEH
            elif last_char == KASRATAN:
                arudi_str = arudi_str[:-1] + YEH if arudi_str.endswith(NOON) else arudi_str + YEH
            elif last_char == FATHA:
                arudi_str += ALEF
            elif last_char == DAMMA:
                arudi_str += WAW
            elif last_char == DAMMATAN:
                arudi_str = arudi_str[:-1] + WAW if arudi_str.endswith(NOON) else arudi_str + WAW
            elif last_char in LONG_VOWELS and len(chars) > 1 and chars[-2] not in TANWEEN_SET:
                arudi_str += last_char

        return arudi_str, pattern_str

    def prepare_text(self, text: str, saturate: bool = True, muqayyad: bool = False) -> tuple[str, str]:
        """
        Converts standard Arabic text into phonetic Arudi writing and extracts its binary pattern.

        Args:
            text (str): Input Arabic verse or hemistich.
            saturate (bool): Whether to apply end-of-shatr saturation (Ishba'). Defaults to True.
            muqayyad (bool): Whether the verse has a restricted/quiescent rhyme (Muqayyad).

        Returns:
            tuple[str, str]: (arudi_phonetic_text, binary_pattern)
        """
        text = text.strip()
        if not text:
            return "", ""

        text = text.replace(ALEF_MADDA, "ءَ" + ALEF)
        text = normalize_orthography(text)
        text = normalize_ligatures(text)
        text = self._normalize_shadda(text)
        preprocessed = self._process_specials_before(text)
        preprocessed = self._resolve_wasl(preprocessed)
        arudi_style, pattern = self._extract_pattern(preprocessed, saturate=saturate, muqayyad=muqayyad)

        # Post-processing special orthography
        arudi_style = arudi_style.replace("ةن", "تن")

        return arudi_style, pattern

prepare_text(text, saturate=True, muqayyad=False)

Converts standard Arabic text into phonetic Arudi writing and extracts its binary pattern.

Parameters:

Name Type Description Default
text str

Input Arabic verse or hemistich.

required
saturate bool

Whether to apply end-of-shatr saturation (Ishba'). Defaults to True.

True
muqayyad bool

Whether the verse has a restricted/quiescent rhyme (Muqayyad).

False

Returns:

Type Description
tuple[str, str]

tuple[str, str]: (arudi_phonetic_text, binary_pattern)

Source code in pyarud/core/phonetics.py
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
def prepare_text(self, text: str, saturate: bool = True, muqayyad: bool = False) -> tuple[str, str]:
    """
    Converts standard Arabic text into phonetic Arudi writing and extracts its binary pattern.

    Args:
        text (str): Input Arabic verse or hemistich.
        saturate (bool): Whether to apply end-of-shatr saturation (Ishba'). Defaults to True.
        muqayyad (bool): Whether the verse has a restricted/quiescent rhyme (Muqayyad).

    Returns:
        tuple[str, str]: (arudi_phonetic_text, binary_pattern)
    """
    text = text.strip()
    if not text:
        return "", ""

    text = text.replace(ALEF_MADDA, "ءَ" + ALEF)
    text = normalize_orthography(text)
    text = normalize_ligatures(text)
    text = self._normalize_shadda(text)
    preprocessed = self._process_specials_before(text)
    preprocessed = self._resolve_wasl(preprocessed)
    arudi_style, pattern = self._extract_pattern(preprocessed, saturate=saturate, muqayyad=muqayyad)

    # Post-processing special orthography
    arudi_style = arudi_style.replace("ةن", "تن")

    return arudi_style, pattern

register_custom_spelling(word, replacement)

Register a custom phonetic spelling for a specific unvocalized word.

Source code in pyarud/core/phonetics.py
137
138
139
def register_custom_spelling(self, word: str, replacement: str) -> None:
    """Register a custom phonetic spelling for a specific unvocalized word."""
    self.replacements[word] = replacement

Qafiyah Analyzer (Rhyme Engine)

pyarud.qafiyah.analyzer.QafiyahAnalyzer

Analyzes Arabic poetic rhyme (Qafiyah) according to classical prosody.

Source code in pyarud/qafiyah/analyzer.py
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
class QafiyahAnalyzer:
    """
    Analyzes Arabic poetic rhyme (Qafiyah) according to classical prosody.
    """

    def __init__(self) -> None:
        self.converter = ArudiConverter()

    def analyze(self, ajuz_text: str, is_muqayyad: bool = False) -> QafiyahAnalysis:
        """
        Extracts the full Qafiyah breakdown for the concluding hemistich (Ajuz).

        Args:
            ajuz_text (str): The second hemistich of the verse.
            is_muqayyad (bool): True if the poem uses a quiescent/restricted Rawi.

        Returns:
            QafiyahAnalysis: Detailed dataclass with Rawi, Wasl, Ridf, Qafiyah span, etc.
        """
        if not ajuz_text.strip():
            return QafiyahAnalysis(rawi="")

        # Get arudi phonetic text and pattern
        arudi_text, pattern = self.converter.prepare_text(ajuz_text, saturate=not is_muqayyad, muqayyad=is_muqayyad)

        # 1. Determine Qafiyah boundaries:
        # From the last Sakin ('0') back to the preceding Sakin ('0'), plus the preceding Mutaharrik ('1').
        qafiyah_pattern = ""
        qafiyah_type_ar = "المتواتر"
        qafiyah_type_en = "Al-Mutawatir"

        # Find indices of Sakins in pattern
        sakin_indices = [i for i, c in enumerate(pattern) if c == "0"]

        if len(sakin_indices) >= 2:
            last_sakin_idx = sakin_indices[-1]
            prev_sakin_idx = sakin_indices[-2]
            start_idx = max(0, prev_sakin_idx - 1)  # include mutaharrik before previous sakin
            qafiyah_pattern = pattern[start_idx : last_sakin_idx + 1]

            # Count mutaharriks between the two sakins
            num_mutaharriks = max(0, last_sakin_idx - prev_sakin_idx - 1)
            if num_mutaharriks == 0:
                qafiyah_type_ar = "المترادف"
                qafiyah_type_en = "Al-Mutaradif"
            elif num_mutaharriks == 1:
                qafiyah_type_ar = "المتواتر"
                qafiyah_type_en = "Al-Mutawatir"
            elif num_mutaharriks == 2:
                qafiyah_type_ar = "المتدارك"
                qafiyah_type_en = "Al-Mutadarak"
            elif num_mutaharriks == 3:
                qafiyah_type_ar = "المتراكب"
                qafiyah_type_en = "Al-Mutarakib"
            else:
                qafiyah_type_ar = "المتكاوس"
                qafiyah_type_en = "Al-Mutakawis"
        elif len(sakin_indices) == 1:
            qafiyah_pattern = pattern[max(0, sakin_indices[0] - 1) :]
            qafiyah_type_ar = "المتواتر"
            qafiyah_type_en = "Al-Mutawatir"

        # 2. Extract Rawi and ancillary rhyme letters
        rawi, rawi_haraka, wasl, khuruj, ridf, tasees, dakhil = self._extract_rhyme_letters(
            ajuz_text, arudi_text, is_muqayyad
        )

        # 3. Extract Qafiyah Text Span
        words = ajuz_text.strip().split()
        qafiyah_text = words[-1] if words else ""
        if len(words) > 1 and len(qafiyah_pattern) > 5:
            qafiyah_text = f"{words[-2]} {words[-1]}"

        classification = "muqayyadah" if is_muqayyad or not wasl else "mutlaqah"

        return QafiyahAnalysis(
            rawi=rawi,
            rawi_haraka=rawi_haraka,
            wasl=wasl,
            khuruj=khuruj,
            ridf=ridf,
            tasees=tasees,
            dakhil=dakhil,
            qafiyah_text=qafiyah_text,
            qafiyah_pattern=qafiyah_pattern,
            qafiyah_type_ar=qafiyah_type_ar,
            qafiyah_type_en=qafiyah_type_en,
            rhyme_classification=classification,
        )

    def _extract_rhyme_letters(
        self, original_text: str, arudi_text: str, is_muqayyad: bool
    ) -> tuple[str, str, str | None, str | None, str | None, str | None, str | None]:
        """
        Identifies the Rawi and accompanying letters: Wasl, Khuruj, Ridf, Ta'sis, Dakhil.
        """
        clean_text = original_text.strip()
        if not clean_text:
            return "", "", None, None, None, None, None

        # Filter out punctuation
        clean_text = "".join(
            c for c in clean_text if c in LETTERS_SET or c in HARAKAT_SET or c in TANWEEN_SET or c == SUKUN or c == " "
        )

        tokens = [c for c in clean_text if c != " "]
        if not tokens:
            return "", "", None, None, None, None, None

        # Analyze from tail to head
        # Find the last base Arabic consonant
        letters_only = [c for c in clean_text if c in LETTERS_SET]
        if not letters_only:
            return "", "", None, None, None, None, None

        rawi = ""
        rawi_haraka = ""
        wasl: str | None = None
        khuruj: str | None = None
        ridf: str | None = None
        tasees: str | None = None
        dakhil: str | None = None

        last_letter = letters_only[-1]
        second_last_letter = letters_only[-2] if len(letters_only) >= 2 else ""
        third_last_letter = letters_only[-3] if len(letters_only) >= 3 else ""

        # Check if last letter is an elongation letter (Alif, Waw, Yeh) or Haa of Wasl
        if last_letter in (ALEF, ALEF_MAKSURA, WAW, YEH):
            wasl = last_letter
            rawi = second_last_letter
            # Check for Ridf before Rawi
            if third_last_letter in (ALEF, ALEF_MAKSURA, WAW, YEH):
                ridf = third_last_letter
            elif len(letters_only) >= 4 and letters_only[-4] == ALEF:
                tasees = ALEF
                dakhil = third_last_letter

        elif last_letter == HAA and len(letters_only) >= 2:
            # Haa can be Rawi (if root letter like وجه) or Wasl (if pronoun like كتابه)
            wasl = HAA
            rawi = second_last_letter
            if third_last_letter in (ALEF, WAW, YEH):
                ridf = third_last_letter
            elif len(letters_only) >= 4 and letters_only[-4] == ALEF:
                tasees = ALEF
                dakhil = third_last_letter

        elif last_letter == NOON and any(c in TANWEEN_SET for c in original_text[-4:]):
            # Trailing Noon from Tanween
            rawi = second_last_letter
        else:
            rawi = last_letter
            if second_last_letter in (ALEF, WAW, YEH):
                ridf = second_last_letter
            elif len(letters_only) >= 3 and letters_only[-3] == ALEF:
                tasees = ALEF
                dakhil = second_last_letter

        # Determine Rawi Haraka
        if rawi:
            idx = clean_text.rfind(rawi)
            if idx != -1 and idx + 1 < len(clean_text):
                next_c = clean_text[idx + 1]
                if next_c in HARAKAT_SET or next_c in TANWEEN_SET:
                    if next_c in (FATHA, FATHATAN):
                        rawi_haraka = "fatha"
                    elif next_c in (DAMMA, DAMMATAN):
                        rawi_haraka = "damma"
                    elif next_c in (KASRA, KASRATAN):
                        rawi_haraka = "kasra"
                elif next_c == SUKUN:
                    rawi_haraka = "sukun"

        return rawi, rawi_haraka, wasl, khuruj, ridf, tasees, dakhil

analyze(ajuz_text, is_muqayyad=False)

Extracts the full Qafiyah breakdown for the concluding hemistich (Ajuz).

Parameters:

Name Type Description Default
ajuz_text str

The second hemistich of the verse.

required
is_muqayyad bool

True if the poem uses a quiescent/restricted Rawi.

False

Returns:

Name Type Description
QafiyahAnalysis QafiyahAnalysis

Detailed dataclass with Rawi, Wasl, Ridf, Qafiyah span, etc.

Source code in pyarud/qafiyah/analyzer.py
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
def analyze(self, ajuz_text: str, is_muqayyad: bool = False) -> QafiyahAnalysis:
    """
    Extracts the full Qafiyah breakdown for the concluding hemistich (Ajuz).

    Args:
        ajuz_text (str): The second hemistich of the verse.
        is_muqayyad (bool): True if the poem uses a quiescent/restricted Rawi.

    Returns:
        QafiyahAnalysis: Detailed dataclass with Rawi, Wasl, Ridf, Qafiyah span, etc.
    """
    if not ajuz_text.strip():
        return QafiyahAnalysis(rawi="")

    # Get arudi phonetic text and pattern
    arudi_text, pattern = self.converter.prepare_text(ajuz_text, saturate=not is_muqayyad, muqayyad=is_muqayyad)

    # 1. Determine Qafiyah boundaries:
    # From the last Sakin ('0') back to the preceding Sakin ('0'), plus the preceding Mutaharrik ('1').
    qafiyah_pattern = ""
    qafiyah_type_ar = "المتواتر"
    qafiyah_type_en = "Al-Mutawatir"

    # Find indices of Sakins in pattern
    sakin_indices = [i for i, c in enumerate(pattern) if c == "0"]

    if len(sakin_indices) >= 2:
        last_sakin_idx = sakin_indices[-1]
        prev_sakin_idx = sakin_indices[-2]
        start_idx = max(0, prev_sakin_idx - 1)  # include mutaharrik before previous sakin
        qafiyah_pattern = pattern[start_idx : last_sakin_idx + 1]

        # Count mutaharriks between the two sakins
        num_mutaharriks = max(0, last_sakin_idx - prev_sakin_idx - 1)
        if num_mutaharriks == 0:
            qafiyah_type_ar = "المترادف"
            qafiyah_type_en = "Al-Mutaradif"
        elif num_mutaharriks == 1:
            qafiyah_type_ar = "المتواتر"
            qafiyah_type_en = "Al-Mutawatir"
        elif num_mutaharriks == 2:
            qafiyah_type_ar = "المتدارك"
            qafiyah_type_en = "Al-Mutadarak"
        elif num_mutaharriks == 3:
            qafiyah_type_ar = "المتراكب"
            qafiyah_type_en = "Al-Mutarakib"
        else:
            qafiyah_type_ar = "المتكاوس"
            qafiyah_type_en = "Al-Mutakawis"
    elif len(sakin_indices) == 1:
        qafiyah_pattern = pattern[max(0, sakin_indices[0] - 1) :]
        qafiyah_type_ar = "المتواتر"
        qafiyah_type_en = "Al-Mutawatir"

    # 2. Extract Rawi and ancillary rhyme letters
    rawi, rawi_haraka, wasl, khuruj, ridf, tasees, dakhil = self._extract_rhyme_letters(
        ajuz_text, arudi_text, is_muqayyad
    )

    # 3. Extract Qafiyah Text Span
    words = ajuz_text.strip().split()
    qafiyah_text = words[-1] if words else ""
    if len(words) > 1 and len(qafiyah_pattern) > 5:
        qafiyah_text = f"{words[-2]} {words[-1]}"

    classification = "muqayyadah" if is_muqayyad or not wasl else "mutlaqah"

    return QafiyahAnalysis(
        rawi=rawi,
        rawi_haraka=rawi_haraka,
        wasl=wasl,
        khuruj=khuruj,
        ridf=ridf,
        tasees=tasees,
        dakhil=dakhil,
        qafiyah_text=qafiyah_text,
        qafiyah_pattern=qafiyah_pattern,
        qafiyah_type_ar=qafiyah_type_ar,
        qafiyah_type_en=qafiyah_type_en,
        rhyme_classification=classification,
    )

Models & Data Classes

pyarud.models.analysis.VerseAnalysis dataclass

Full prosodic analysis of an entire poetic verse (Bait).

Source code in pyarud/models/analysis.py
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
@dataclass(slots=True)
class VerseAnalysis:
    """Full prosodic analysis of an entire poetic verse (Bait)."""

    verse_index: int = 0
    sadr_text: str = ""
    ajuz_text: str = ""
    meter_key: str = "unknown"
    meter_name_ar: str = "غير معروف"
    meter_name_en: str = "Unknown"
    bahr_type: str = "tam"
    standard_pattern: str = ""
    score: float = 0.0
    sadr: ShatrAnalysis | None = None
    ajuz: ShatrAnalysis | None = None
    qafiyah: QafiyahAnalysis | None = None
    is_valid: bool = True
    errors: list[str] = field(default_factory=list)

    @property
    def meter(self) -> str:
        """Alias for meter_key for ergonomic access."""
        return self.meter_key

    @property
    def is_sound(self) -> bool:
        """Alias for is_valid."""
        return self.is_valid

    def __bool__(self) -> bool:
        """Truthiness of the verse evaluation: True if verse has text."""
        return bool(self.sadr_text or self.ajuz_text)

    def __str__(self) -> str:
        status = "صحيح" if self.is_valid else "مكسور"
        return f"[{self.meter_name_ar} ({self.bahr_type})] {self.sadr_text} ... {self.ajuz_text} ({status})"

    def to_dict(self) -> dict[str, Any]:
        """Convert entire verse analysis to a deeply nested JSON-serializable dictionary."""
        return {
            "verse_index": self.verse_index,
            "sadr_text": self.sadr_text,
            "ajuz_text": self.ajuz_text,
            "meter_key": self.meter_key,
            "meter_name_ar": self.meter_name_ar,
            "meter_name_en": self.meter_name_en,
            "bahr_type": self.bahr_type,
            "standard_pattern": self.standard_pattern,
            "score": self.score,
            "is_valid": self.is_valid,
            "errors": list(self.errors),
            "sadr": self.sadr.to_dict() if self.sadr is not None else None,
            "ajuz": self.ajuz.to_dict() if self.ajuz is not None else None,
            "qafiyah": self.qafiyah.to_dict() if self.qafiyah is not None else None,
        }

is_sound property

Alias for is_valid.

meter property

Alias for meter_key for ergonomic access.

__bool__()

Truthiness of the verse evaluation: True if verse has text.

Source code in pyarud/models/analysis.py
137
138
139
def __bool__(self) -> bool:
    """Truthiness of the verse evaluation: True if verse has text."""
    return bool(self.sadr_text or self.ajuz_text)

to_dict()

Convert entire verse analysis to a deeply nested JSON-serializable dictionary.

Source code in pyarud/models/analysis.py
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
def to_dict(self) -> dict[str, Any]:
    """Convert entire verse analysis to a deeply nested JSON-serializable dictionary."""
    return {
        "verse_index": self.verse_index,
        "sadr_text": self.sadr_text,
        "ajuz_text": self.ajuz_text,
        "meter_key": self.meter_key,
        "meter_name_ar": self.meter_name_ar,
        "meter_name_en": self.meter_name_en,
        "bahr_type": self.bahr_type,
        "standard_pattern": self.standard_pattern,
        "score": self.score,
        "is_valid": self.is_valid,
        "errors": list(self.errors),
        "sadr": self.sadr.to_dict() if self.sadr is not None else None,
        "ajuz": self.ajuz.to_dict() if self.ajuz is not None else None,
        "qafiyah": self.qafiyah.to_dict() if self.qafiyah is not None else None,
    }

pyarud.models.analysis.PoemAnalysis dataclass

Comprehensive prosodic and metric analysis of an entire multi-verse poem.

Source code in pyarud/models/analysis.py
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
@dataclass(slots=True)
class PoemAnalysis:
    """Comprehensive prosodic and metric analysis of an entire multi-verse poem."""

    meter_key: str = "unknown"
    meter_name_ar: str = "غير محدد"
    meter_name_en: str = "Unknown"
    bahr_type: str = "unknown"
    verses: list[VerseAnalysis] = field(default_factory=list)
    average_score: float = 0.0
    is_homogeneous: bool = True
    dominant_rawi: str | None = None
    total_verses: int = 0
    valid_verses_count: int = 0

    def __iter__(self) -> Iterator[VerseAnalysis]:
        """Iterate over the verses in the poem."""
        return iter(self.verses)

    def __len__(self) -> int:
        """Total number of verses in the poem."""
        return len(self.verses)

    def __getitem__(self, index: int) -> VerseAnalysis:
        """Access a verse analysis by index."""
        return self.verses[index]

    def __bool__(self) -> bool:
        """True if the poem has analyzed verses and a recognized meter."""
        return bool(self.verses) and self.meter_key != "unknown"

    def __str__(self) -> str:
        return (
            f"Poem({self.meter_name_ar}, {self.total_verses} verses, "
            f"{self.valid_verses_count} valid, avg_score={self.average_score:.2f})"
        )

    def to_dict(self) -> dict[str, Any]:
        """Convert entire poem analysis to dictionary."""
        return {
            "meter_key": self.meter_key,
            "meter_name_ar": self.meter_name_ar,
            "meter_name_en": self.meter_name_en,
            "bahr_type": self.bahr_type,
            "average_score": self.average_score,
            "is_homogeneous": self.is_homogeneous,
            "dominant_rawi": self.dominant_rawi,
            "total_verses": self.total_verses,
            "valid_verses_count": self.valid_verses_count,
            "verses": [v.to_dict() for v in self.verses],
        }

__bool__()

True if the poem has analyzed verses and a recognized meter.

Source code in pyarud/models/analysis.py
192
193
194
def __bool__(self) -> bool:
    """True if the poem has analyzed verses and a recognized meter."""
    return bool(self.verses) and self.meter_key != "unknown"

__getitem__(index)

Access a verse analysis by index.

Source code in pyarud/models/analysis.py
188
189
190
def __getitem__(self, index: int) -> VerseAnalysis:
    """Access a verse analysis by index."""
    return self.verses[index]

__iter__()

Iterate over the verses in the poem.

Source code in pyarud/models/analysis.py
180
181
182
def __iter__(self) -> Iterator[VerseAnalysis]:
    """Iterate over the verses in the poem."""
    return iter(self.verses)

__len__()

Total number of verses in the poem.

Source code in pyarud/models/analysis.py
184
185
186
def __len__(self) -> int:
    """Total number of verses in the poem."""
    return len(self.verses)

to_dict()

Convert entire poem analysis to dictionary.

Source code in pyarud/models/analysis.py
202
203
204
205
206
207
208
209
210
211
212
213
214
215
def to_dict(self) -> dict[str, Any]:
    """Convert entire poem analysis to dictionary."""
    return {
        "meter_key": self.meter_key,
        "meter_name_ar": self.meter_name_ar,
        "meter_name_en": self.meter_name_en,
        "bahr_type": self.bahr_type,
        "average_score": self.average_score,
        "is_homogeneous": self.is_homogeneous,
        "dominant_rawi": self.dominant_rawi,
        "total_verses": self.total_verses,
        "valid_verses_count": self.valid_verses_count,
        "verses": [v.to_dict() for v in self.verses],
    }

pyarud.models.analysis.ShatrAnalysis dataclass

Prosodic analysis of a single hemistich (Sadr or Ajuz).

Source code in pyarud/models/analysis.py
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
@dataclass(slots=True)
class ShatrAnalysis:
    """Prosodic analysis of a single hemistich (Sadr or Ajuz)."""

    text: str
    arudi_text: str
    pattern: str
    feet: list[FootAnalysis] = field(default_factory=list)
    score: float = 1.0
    is_valid: bool = True

    def __iter__(self) -> Iterator[FootAnalysis]:
        """Iterate over individual feet within this hemistich."""
        return iter(self.feet)

    def __len__(self) -> int:
        """Number of feet in this hemistich."""
        return len(self.feet)

    def __getitem__(self, index: int) -> FootAnalysis:
        """Access foot analysis by index."""
        return self.feet[index]

    def __bool__(self) -> bool:
        return bool(self.text)

    def to_dict(self) -> dict[str, Any]:
        """Convert shatr analysis to a dictionary."""
        return {
            "text": self.text,
            "arudi_text": self.arudi_text,
            "pattern": self.pattern,
            "score": self.score,
            "is_valid": self.is_valid,
            "feet": [f.to_dict() for f in self.feet],
        }

__getitem__(index)

Access foot analysis by index.

Source code in pyarud/models/analysis.py
61
62
63
def __getitem__(self, index: int) -> FootAnalysis:
    """Access foot analysis by index."""
    return self.feet[index]

__iter__()

Iterate over individual feet within this hemistich.

Source code in pyarud/models/analysis.py
53
54
55
def __iter__(self) -> Iterator[FootAnalysis]:
    """Iterate over individual feet within this hemistich."""
    return iter(self.feet)

__len__()

Number of feet in this hemistich.

Source code in pyarud/models/analysis.py
57
58
59
def __len__(self) -> int:
    """Number of feet in this hemistich."""
    return len(self.feet)

to_dict()

Convert shatr analysis to a dictionary.

Source code in pyarud/models/analysis.py
68
69
70
71
72
73
74
75
76
77
def to_dict(self) -> dict[str, Any]:
    """Convert shatr analysis to a dictionary."""
    return {
        "text": self.text,
        "arudi_text": self.arudi_text,
        "pattern": self.pattern,
        "score": self.score,
        "is_valid": self.is_valid,
        "feet": [f.to_dict() for f in self.feet],
    }

pyarud.models.analysis.FootAnalysis dataclass

Detailed metric analysis of a single poetic foot (Taf'eela).

Source code in pyarud/models/analysis.py
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
@dataclass(slots=True)
class FootAnalysis:
    """Detailed metric analysis of a single poetic foot (Taf'eela)."""

    foot_index: int
    expected_pattern: str
    actual_segment: str
    base_tafeela: str = ""
    actual_tafeela: str = ""
    zihaf_name_ar: str = "سالمة"
    zihaf_name_en: str = "Salim"
    score: float = 1.0
    status: str = "ok"  # 'ok', 'broken', 'missing', 'extra_bits'

    @property
    def is_valid(self) -> bool:
        """True if the foot matched standard or permitted Zihaf variations."""
        return self.status == "ok"

    def to_dict(self) -> dict[str, Any]:
        """Convert foot analysis to a JSON-serializable dictionary."""
        return asdict(self)

    def __bool__(self) -> bool:
        return bool(self.actual_segment)

    def __str__(self) -> str:
        name = self.actual_tafeela or self.base_tafeela or "تفعيلة"
        return f"{name} ({self.actual_segment}) - {self.zihaf_name_ar}"

is_valid property

True if the foot matched standard or permitted Zihaf variations.

to_dict()

Convert foot analysis to a JSON-serializable dictionary.

Source code in pyarud/models/analysis.py
30
31
32
def to_dict(self) -> dict[str, Any]:
    """Convert foot analysis to a JSON-serializable dictionary."""
    return asdict(self)

pyarud.models.analysis.QafiyahAnalysis dataclass

Comprehensive Rhyme (علم القافية) extraction and classification.

Source code in pyarud/models/analysis.py
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
@dataclass(slots=True)
class QafiyahAnalysis:
    """Comprehensive Rhyme (علم القافية) extraction and classification."""

    rawi: str = ""
    rawi_haraka: str = ""
    wasl: str | None = None
    khuruj: str | None = None
    ridf: str | None = None
    tasees: str | None = None
    dakhil: str | None = None
    qafiyah_text: str = ""
    qafiyah_pattern: str = ""
    qafiyah_type_ar: str = "المتواتر"
    qafiyah_type_en: str = "Al-Mutawatir"
    rhyme_classification: str = "mutlaqah"  # 'mutlaqah' or 'muqayyadah'

    def to_dict(self) -> dict[str, Any]:
        """Convert rhyme analysis to a dictionary."""
        return asdict(self)

    def __bool__(self) -> bool:
        return bool(self.rawi)

    def __str__(self) -> str:
        return f"الروي: {self.rawi} ({self.rawi_haraka}) | القافية: {self.qafiyah_type_ar}"

to_dict()

Convert rhyme analysis to a dictionary.

Source code in pyarud/models/analysis.py
97
98
99
def to_dict(self) -> dict[str, Any]:
    """Convert rhyme analysis to a dictionary."""
    return asdict(self)