ACE Grammar Engine
Primary reverse-engine analyzer functions.
<?php
declare(strict_types=1);
/**
* Assign the three root radicals from the end of the analyzed stem.
*
* Structural prefix letters such as imperfect ي and Form V/VI ت
* must already be identified or skipped here.
*
* A cluster containing shadda represents a repeated radical, but the
* repeated letter must occupy only its correct root position.
*/
function ace_assign_root_positions(array $analysis): array
{
$clusters = $analysis['structural_clusters']
?? $analysis['clusters']
?? array();
$analysis['r0'] = '';
$analysis['r1'] = '';
$analysis['r2'] = '';
$analysis['v1'] = '';
$analysis['v2'] = '';
$analysis['r0_marks'] = '';
$analysis['r1_marks'] = '';
$analysis['r2_marks'] = '';
$analysis['r1_repeat'] = false;
$analysis['r2_repeat'] = false;
if (count($clusters) === 0) {
$analysis['root'] = '';
return $analysis;
}
/*
* Remove clusters that cannot be root radicals.
*
* ي = imperfect person marker
* ت = Form V/VI structural marker when it follows the person marker
*/
$workingClusters = array_values($clusters);
if (
isset($workingClusters[0]['letter'])
&& in_array(
$workingClusters[0]['letter'],
array('ي', 'ت', 'أ', 'ا', 'ن'),
true
)
) {
array_shift($workingClusters);
}
if (
isset($workingClusters[0]['letter'])
&& $workingClusters[0]['letter'] === 'ت'
&& count($workingClusters) >= 4
) {
array_shift($workingClusters);
}
/*
* Assign r2 first.
*
* The final consonantal cluster is normally the third radical.
*/
$r2Index = ace_find_previous_radical_cluster(
$workingClusters,
count($workingClusters) - 1
);
if ($r2Index === null) {
$analysis['root'] = '';
return $analysis;
}
$r2Cluster = $workingClusters[$r2Index];
$analysis['r2'] = $r2Cluster['letter'] ?? '';
$analysis['r2_marks'] = $r2Cluster['marks'] ?? '';
/*
* Assign r1 before r2.
*
* Skip long structural alif in patterns such as Form VI:
* يَتَدَارَسُ → د ا ر س
*
* The alif is v1, not a radical.
*/
$r1Index = ace_find_previous_radical_cluster(
$workingClusters,
$r2Index - 1
);
if ($r1Index === null) {
$analysis['root'] = $analysis['r2'];
return $analysis;
}
$r1Cluster = $workingClusters[$r1Index];
$analysis['r1'] = $r1Cluster['letter'] ?? '';
$analysis['r1_marks'] = $r1Cluster['marks'] ?? '';
/*
* A shadda on r1 indicates Form II or Form V middle-radical
* repetition. It does not mean that r0 is also the same letter.
*/
if (ace_cluster_has_shadda($r1Cluster)) {
$analysis['r1_repeat'] = true;
}
/*
* Assign r0 before r1.
*
* If a long alif occurs between r0 and r1, record it as v1
* and continue backward to obtain the real first radical.
*/
$r0Index = ace_find_previous_radical_cluster(
$workingClusters,
$r1Index - 1
);
if ($r0Index !== null) {
$r0Cluster = $workingClusters[$r0Index];
$analysis['r0'] = $r0Cluster['letter'] ?? '';
$analysis['r0_marks'] = $r0Cluster['marks'] ?? '';
}
/*
* Detect a long alif between r0 and r1.
*/
if (
$r0Index !== null
&& $r1Index - $r0Index > 1
) {
for ($index = $r0Index + 1; $index < $r1Index; $index++) {
$letter = $workingClusters[$index]['letter'] ?? '';
if (in_array($letter, array('ا', 'ى'), true)) {
$analysis['v1'] = $letter;
break;
}
}
}
/*
* Detect final-radical repetition, used especially by Form IX.
*/
if (ace_cluster_has_shadda($r2Cluster)) {
$analysis['r2_repeat'] = true;
}
/*
* Construct the root in root order only after r0, r1 and r2
* have been assigned.
*/
$rootParts = array(
$analysis['r0'],
$analysis['r1'],
$analysis['r2'],
);
$nonEmptyRootParts = array();
foreach ($rootParts as $rootPart) {
if ($rootPart !== '') {
$nonEmptyRootParts[] = $rootPart;
}
}
$analysis['root'] = implode('', $nonEmptyRootParts);
return $analysis;
}
/**
* Find the preceding cluster that may function as a root radical.
*
* Long alif is normally a pattern vowel rather than an independent
* radical in the current strong-root Form V/VI analysis.
*/
function ace_find_previous_radical_cluster(
array $clusters,
int $startIndex
): ?int {
for ($index = $startIndex; $index >= 0; $index--) {
$letter = $clusters[$index]['letter'] ?? '';
if ($letter === '') {
continue;
}
/*
* Do not assign a medial long alif as a radical.
*
* Weak-root restoration can later reintroduce و or ي when
* morphological evidence requires it.
*/
if (in_array($letter, array('ا', 'ى', 'ٰ'), true)) {
continue;
}
return $index;
}
return null;
}
/**
* Determine whether a cluster contains shadda.
*/
function ace_cluster_has_shadda(array $cluster): bool
{
$marks = $cluster['marks'] ?? '';
return mb_strpos($marks, 'ّ') !== false;
}
/**
* Remove Arabic diacritical marks while preserving Arabic letters.
*/
if (!function_exists('ace_remove_arabic_marks')) {
function ace_remove_arabic_marks(string $text): string
{
// Remove Qur'anic annotation marks.
$text = preg_replace('/[\x{0610}-\x{061A}]/u', '', $text);
// Remove Arabic harakat and common combining marks:
// fathatan, dammatan, kasratan, fatha, damma, kasra,
// shadda, sukun, superscript alif, and related marks.
$text = preg_replace('/[\x{064B}-\x{065F}]/u', '', $text);
// Remove superscript alif and additional Arabic combining marks.
$text = preg_replace('/[\x{0670}\x{06D6}-\x{06ED}]/u', '', $text);
return $text ?? '';
}
}
/**
* Prevent final weak radicals from being mistaken for suffixes.
*/
function ace_is_valid_suffix_removal(
string $token,
string $suffix,
string $candidateStem,
array $suffixEntry
): bool {
$plainToken = ace_remove_arabic_marks($token);
$plainStem = ace_remove_arabic_marks($candidateStem);
/*
* A final ي may be:
* 1. a genuine 2fs suffix, or
* 2. the final radical of a defective verb.
*
* Do not remove it when the remaining imperfect stem would
* contain only the person marker plus one radical.
*
* Example:
* يَقِي → removing ي gives يَقِ / يق
* This destroys the defective radical of root و-ق-ي.
*/
if ($suffix === 'ي') {
$letters = preg_split('//u', $plainStem, -1, PREG_SPLIT_NO_EMPTY);
$initial = $letters[0] ?? '';
$isImperfectInitial = in_array(
$initial,
['ي', 'ت', 'أ', 'ن'],
true
);
if ($isImperfectInitial && count($letters) <= 2) {
return false;
}
}
return true;
}
/**
* Generate weak-root candidates when only two radicals remain.
*
* The missing position must be established structurally before
* candidates are generated.
*
* @return array<int, string>
*/
function ace_generate_missing_weak_candidates(
string $r0,
string $r1,
string $r2,
string $missingPosition
): array {
$weakRadicals = ['و', 'ي'];
$candidates = [];
foreach ($weakRadicals as $weakRadical) {
if ($missingPosition === 'r1') {
if ($r0 !== '' && $r2 !== '') {
$candidates[] = $r0 . $weakRadical . $r2;
}
} elseif ($missingPosition === 'r2') {
if ($r0 !== '' && $r1 !== '') {
$candidates[] = $r0 . $r1 . $weakRadical;
}
} elseif ($missingPosition === 'r0') {
if ($r1 !== '' && $r2 !== '') {
$candidates[] = $weakRadical . $r1 . $r2;
}
}
}
return array_values(array_unique($candidates));
}
/*
* ============================================================
* ARABIC UNICODE CONSTANTS
* ============================================================
*/
const ACE_FATHATAN = "\u{064B}"; // ً
const ACE_DAMMATAN = "\u{064C}"; // ٌ
const ACE_KASRATAN = "\u{064D}"; // ٍ
const ACE_FATHA = "\u{064E}"; // َ
const ACE_DAMMA = "\u{064F}"; // ُ
const ACE_KASRA = "\u{0650}"; // ِ
const ACE_SHADDA = "\u{0651}"; // ّ
const ACE_SUKUN = "\u{0652}"; // ْ
const ACE_DAGGER_ALIF = "\u{0670}"; // ٰ
const ACE_TATWEEL = "\u{0640}"; // ـ
/**
* Split a UTF-8 string into Unicode characters.
*
* @return list<string>
*/
/**
* Run one Arabic token through the complete reverse-engine pipeline.
*/
/**
* Safely convert any ACE value to displayable text.
*/
/**
* Return the visible text of one structural cluster.
*/
function ace_cluster_to_string(mixed $cluster): string
{
if (is_string($cluster)) {
return $cluster;
}
if (!is_array($cluster)) {
return '';
}
if (isset($cluster['text']) && is_scalar($cluster['text'])) {
return (string) $cluster['text'];
}
$letter = isset($cluster['letter']) && is_scalar($cluster['letter'])
? (string) $cluster['letter']
: '';
$marks = $cluster['marks'] ?? [];
if (is_array($marks)) {
$marks = implode('', array_map(
function ($mark) {
return is_scalar($mark) ? (string) $mark : '';
},
$marks
));
} elseif (!is_scalar($marks)) {
$marks = '';
}
return $letter . (string) $marks;
}
/**
* ============================================================
* STEM-LENGTH FORM CANDIDATES
* ============================================================
*
* Determine the effective stem length after suffix removal and
* provide form candidates that are structurally compatible with
* that length.
*
* Length rules:
* - Arabic vowel marks do not count as letters.
* - Shadda represents a doubled consonant and therefore adds one
* structural unit.
* - Imperfect stems include the imperfect person marker (VN), so
* they use a separate length profile.
*
* This stage does not replace ace_detect_form(). It supplies a
* length constraint that later form-detection stages can combine
* with VN, VM, FS, long-vowel, and repetition evidence.
*
* Adds:
* - stem_length_after_suffix_removal
* - stem_surface_length
* - stem_shadda_count
* - stem_effective_length
* - stem_length_context
* - stem_length_form_map
* - potential_forms_by_stem_length
* - potential_form_by_stem_length
* - detected_form_length_supported
* - length_supported_form_candidates
*
* @param array<string, mixed> $analysis
*
* @return array<string, mixed>
*/
function ace_identify_potential_forms_by_stem_length(
array $analysis
): array {
$markedStem = isset($analysis['marked_stem'])
? (string) $analysis['marked_stem']
: '';
$plainStem = isset($analysis['plain_stem'])
? (string) $analysis['plain_stem']
: (
function_exists('ace_remove_arabic_marks')
? ace_remove_arabic_marks($markedStem)
: preg_replace('/[\\x{064B}-\\x{065F}\\x{0670}]/u', '', $markedStem)
);
if (!is_string($plainStem)) {
$plainStem = '';
}
/*
* Count surface letters after suffix removal.
*/
$surfaceLength = mb_strlen($plainStem, 'UTF-8');
/*
* Count shadda before marks are removed. Each shadda represents
* one additional consonantal position.
*/
$shaddaCount = preg_match_all('/\\x{0651}/u', $markedStem, $matches);
$shaddaCount = is_int($shaddaCount) ? $shaddaCount : 0;
$effectiveLength = $surfaceLength + $shaddaCount;
/*
* Prefer the baseline imperfect-group decision. Fall back to
* the first plain letter only when that field is unavailable.
*/
if (array_key_exists('is_imperfect_group', $analysis)) {
$isImperfect = (bool) $analysis['is_imperfect_group'];
} else {
$firstLetter = $plainStem !== ''
? mb_substr($plainStem, 0, 1, 'UTF-8')
: '';
$isImperfect = in_array(
$firstLetter,
array('ي', 'ت', 'أ', 'ن'),
true
);
}
$context = $isImperfect ? 'imperfect' : 'non_imperfect';
/*
* Canonical effective structural lengths for strong triliteral
* stems after suffix removal.
*
* These are candidate constraints, not final form decisions.
*/
$formLengthMap = $isImperfect
? array(
'I' => 4,
'II' => 5,
'III' => 5,
'IV' => 4,
'V' => 6,
'VI' => 6,
'VII' => 5,
'VIII' => 5,
'IX' => 5,
'X' => 7,
)
: array(
'I' => 3,
'II' => 4,
'III' => 4,
'IV' => 4,
'V' => 5,
'VI' => 5,
'VII' => 5,
'VIII' => 5,
'IX' => 5,
'X' => 6,
);
$potentialForms = array();
foreach ($formLengthMap as $form => $requiredLength) {
if ($effectiveLength === $requiredLength) {
$potentialForms[] = $form;
}
}
$analysis['stem_length_after_suffix_removal'] = $effectiveLength;
$analysis['stem_surface_length'] = $surfaceLength;
$analysis['stem_shadda_count'] = $shaddaCount;
$analysis['stem_effective_length'] = $effectiveLength;
$analysis['stem_length_context'] = $context;
$analysis['stem_length_form_map'] = $formLengthMap;
$analysis['potential_forms_by_stem_length'] = $potentialForms;
$analysis['potential_form_by_stem_length'] = count($potentialForms) === 1
? $potentialForms[0]
: '';
/*
* Validate a form already selected by an earlier/later stage.
* The pipeline calls this function both before form detection
* and after form-candidate generation.
*/
$detectedForm = '';
foreach (array('detected_form', 'form', 'selected_form') as $field) {
if (isset($analysis[$field]) && is_string($analysis[$field])) {
$candidate = trim($analysis[$field]);
if ($candidate !== '') {
$detectedForm = $candidate;
break;
}
}
}
$analysis['detected_form_length_supported'] = $detectedForm === ''
? null
: in_array($detectedForm, $potentialForms, true);
/*
* Intersect downstream form candidates with the length filter,
* without erasing the original candidate collection.
*/
$existingCandidates = array();
foreach (array('form_candidates', 'potential_forms') as $field) {
if (isset($analysis[$field]) && is_array($analysis[$field])) {
$existingCandidates = $analysis[$field];
break;
}
}
$normalizedExistingCandidates = array();
foreach ($existingCandidates as $candidate) {
if (is_string($candidate) && trim($candidate) !== '') {
$normalizedExistingCandidates[] = trim($candidate);
continue;
}
if (is_array($candidate)) {
foreach (array('form', 'name', 'value') as $field) {
if (
isset($candidate[$field])
&& is_string($candidate[$field])
&& trim($candidate[$field]) !== ''
) {
$normalizedExistingCandidates[] = trim(
$candidate[$field]
);
break;
}
}
}
}
$analysis['length_supported_form_candidates'] =
$normalizedExistingCandidates === array()
? $potentialForms
: array_values(
array_intersect(
array_unique($normalizedExistingCandidates),
$potentialForms
)
);
return $analysis;
}
/**
* Run the complete ACE reverse-engine pipeline.
*
* Pipeline order:
*
* 0. Analyze and validate suffix removal.
* 1. Establish baseline/display fields.
* 2. Identify possible forms by stem length.
* 3. Extract structural stem positions.
* 4. Extract VN, VM, and form-sign information.
* 5. Assign root positions and structural features.
* 6. Continue form and root reconstruction.
*
* Driver rules:
*
* - Preserve all validated suffix-removal fields.
* - Run optional stages through protected stage runners.
* - Preserve accumulated analysis when a stage is missing, throws an
* exception, or returns a non-array value.
* - Merge every valid stage result into the accumulated analysis.
* - Extract VN, VM, and the form sign before form detection.
* - Run structural root reconstruction before weak-root reconstruction.
*
* @param string $token
* @param array<string, mixed> $masterSuffixes
*
* @return array<string, mixed>
*/
/*
function ace_run_reverse_engine_pipeline(
string $token,
array $masterSuffixes = array()
): array {
try {
$analysis = ace_analyze_validated_suffix_removal(
$token,
$masterSuffixes
);
} catch (\Throwable $exception) {
$analysis = array(
'pipeline_errors' => array(
array(
'stage' => 'ace_analyze_validated_suffix_removal',
'message' => $exception->getMessage(),
),
),
);
}
if (!is_array($analysis)) {
$analysis = array(
'pipeline_errors' => array(
array(
'stage' => 'ace_analyze_validated_suffix_removal',
'message' => (
'Validated suffix-removal stage did not return an array.'
),
),
),
);
}
$analysis['pipeline_errors'] =
isset($analysis['pipeline_errors'])
&& is_array($analysis['pipeline_errors'])
? $analysis['pipeline_errors']
: array();
$analysis['token'] = isset($analysis['token'])
? (string) $analysis['token']
: $token;
$analysis['original_token'] = isset($analysis['original_token'])
? (string) $analysis['original_token']
: $token;
$analysis['token_length'] = isset($analysis['token_length'])
? (int) $analysis['token_length']
: mb_strlen($token, 'UTF-8');
$analysis['matched_suffix'] = isset($analysis['matched_suffix'])
? (string) $analysis['matched_suffix']
: '';
$analysis['matched_suffix_entry'] =
$analysis['matched_suffix_entry'] ?? '';
$analysis['matched_suffix_length'] =
isset($analysis['matched_suffix_length'])
? (int) $analysis['matched_suffix_length']
: mb_strlen(
(string) $analysis['matched_suffix'],
'UTF-8'
);
$analysis['marked_stem'] = isset($analysis['marked_stem'])
? (string) $analysis['marked_stem']
: $token;
$analysis['token_length_after_suffix_removal'] =
isset($analysis['token_length_after_suffix_removal'])
? (int) $analysis['token_length_after_suffix_removal']
: mb_strlen(
(string) $analysis['marked_stem'],
'UTF-8'
);
$analysis['plain_stem'] = isset($analysis['plain_stem'])
? (string) $analysis['plain_stem']
: ace_remove_arabic_marks(
(string) $analysis['marked_stem']
);
$analysis['plain_stem_length'] =
isset($analysis['plain_stem_length'])
? (int) $analysis['plain_stem_length']
: mb_strlen(
(string) $analysis['plain_stem'],
'UTF-8'
);
$requiredDisplayFields = array(
'token' => $analysis['token'],
'original_token' =>
$analysis['original_token'],
'token_length' =>
$analysis['token_length'],
'matched_suffix' =>
$analysis['matched_suffix'],
'matched_suffix_entry' =>
$analysis['matched_suffix_entry'],
'matched_suffix_length' =>
$analysis['matched_suffix_length'],
'marked_stem' =>
$analysis['marked_stem'],
'token_length_after_suffix_removal' =>
$analysis['token_length_after_suffix_removal'],
'plain_stem' =>
$analysis['plain_stem'],
'plain_stem_length' =>
$analysis['plain_stem_length'],
);
$analysis = $runAnalysisStage(
'ace_detect_form',
$analysis
);
$analysis = $runAnalysisStage(
'ace_build_structure',
$analysis
);
// Stage 12: Extract direct structural candidates.
$analysis = $runAnalysisStage(
'ace_extract_root_candidates',
$analysis
);
// Stage 13: Generate additional structural candidates.
$analysis = $runAnalysisStage(
'ace_generate_root_candidates',
$analysis
);
// Stage 16: Validate structural candidates.
$analysis = $runAnalysisStage(
'ace_validate_root_candidates',
$analysis
);
// Stage 17: Build the unified structural candidate collection.
$analysis = $runAnalysisStage(
'ace_build_root_candidates',
$analysis
);
// Stage 18: Select the preliminary structural root.
$analysis = $runAnalysisStage(
'ace_select_final_root',
$analysis
);
// Stage 19: Identify structural form candidates.
$analysis = $runAnalysisStage(
'ace_identify_form_candidates',
$analysis
);
$analysis = $runAnalysisStage(
'ace_identify_potential_forms_by_stem_length',
$analysis
);
// Stage 20: Select the best structural root candidate.
$analysis = $runAnalysisStage(
'ace_select_best_root_candidate',
$analysis
);
// Stage 21: Detect visible or suspected weak radicals.
$analysis = $runAnalysisStage(
'ace_detect_weak_radicals',
$analysis
);
// Stage 22: Resolve assimilated or mithal roots.
$analysis = $runAnalysisStage(
'ace_resolve_mithal_roots',
$analysis
);
// Stage 23: Resolve hollow roots.
$analysis = $runAnalysisStage(
'ace_resolve_hollow_roots',
$analysis
);
// Stage 24: Resolve defective roots.
$analysis = $runAnalysisStage(
'ace_resolve_defective_roots',
$analysis
);
// Stage 25: Resolve hamzated roots.
$analysis = $runAnalysisStage(
'ace_resolve_hamzated_roots',
$analysis
);
// Stage 26: Merge all weak-root candidate collections.
$analysis = $runAnalysisStage(
'ace_merge_weak_root_candidates',
$analysis
);
// Stage 27: Score the merged weak-root candidates.
$analysis = $runAnalysisStage(
'ace_score_weak_root_candidates',
$analysis
);
// Stage 28: Select the final lexical root.
$analysis = $runAnalysisStage(
'ace_select_final_lexical_root',
$analysis
);
foreach ($requiredDisplayFields as $field => $value) {
$analysis[$field] = $value;
}
$analysis['letters'] =
isset($analysis['letters'])
&& is_array($analysis['letters'])
? $analysis['letters']
: array();
$analysis['structural_clusters'] =
isset($analysis['structural_clusters'])
&& is_array($analysis['structural_clusters'])
? $analysis['structural_clusters']
: array();
$analysis['clusters'] =
isset($analysis['clusters'])
&& is_array($analysis['clusters'])
? $analysis['clusters']
: $analysis['structural_clusters'];
$analysis['root_candidates'] =
isset($analysis['root_candidates'])
&& is_array($analysis['root_candidates'])
? $analysis['root_candidates']
: array();
$analysis['validated_roots'] =
isset($analysis['validated_roots'])
&& is_array($analysis['validated_roots'])
? $analysis['validated_roots']
: array();
$analysis['valid_roots'] =
isset($analysis['valid_roots'])
&& is_array($analysis['valid_roots'])
? $analysis['valid_roots']
: array();
$analysis['form_candidates'] =
isset($analysis['form_candidates'])
&& is_array($analysis['form_candidates'])
? $analysis['form_candidates']
: array();
$analysis['potential_forms_by_stem_length'] =
isset($analysis['potential_forms_by_stem_length'])
&& is_array($analysis['potential_forms_by_stem_length'])
? $analysis['potential_forms_by_stem_length']
: array();
$analysis['length_supported_form_candidates'] =
isset($analysis['length_supported_form_candidates'])
&& is_array($analysis['length_supported_form_candidates'])
? $analysis['length_supported_form_candidates']
: array();
$analysis['weak_radicals'] =
isset($analysis['weak_radicals'])
&& is_array($analysis['weak_radicals'])
? $analysis['weak_radicals']
: array();
$analysis['weak_root_candidates'] =
isset($analysis['weak_root_candidates'])
&& is_array($analysis['weak_root_candidates'])
? $analysis['weak_root_candidates']
: array();
$analysis['scored_weak_root_candidates'] =
isset($analysis['scored_weak_root_candidates'])
&& is_array($analysis['scored_weak_root_candidates'])
? $analysis['scored_weak_root_candidates']
: array();
$analysis['pipeline_errors'] =
isset($analysis['pipeline_errors'])
&& is_array($analysis['pipeline_errors'])
? $analysis['pipeline_errors']
: array();
$analysis['root'] = isset($analysis['root'])
? (string) $analysis['root']
: '';
$analysis['final_root'] = isset($analysis['final_root'])
? (string) $analysis['final_root']
: '';
$analysis['selected_root'] = isset($analysis['selected_root'])
? (string) $analysis['selected_root']
: '';
$analysis['final_lexical_root'] =
isset($analysis['final_lexical_root'])
? (string) $analysis['final_lexical_root']
: '';
return $analysis;
}
*/
/**
* ============================================================
* FUNCTION 11
* Build Normalized Structure
* ============================================================
*
* Builds a stable structural record while preserving every field
* produced by earlier pipeline stages.
*
* This stage also normalizes the stem-length measurements required
* by downstream form analysis.
*
* Length fields:
* - token_length
* - matched_suffix_length
* - token_length_after_suffix_removal
* - marked_stem_length
* - plain_stem_length
* - expanded_skeleton_length
* - structural_letter_count
*
* Important:
* - token_length_after_suffix_removal counts the marked stem,
* including Arabic diacritics.
* - plain_stem_length counts letters after diacritics are removed.
* - structural_letter_count uses the extracted letters array where
* available.
*
* This function does not detect or change the verb form. It only
* prepares reliable measurements for downstream form detection and
* validation.
*
* @param array<string, mixed> $analysis
*
* @return array<string, mixed>
*/
function ace_build_structure(array $analysis): array
{
/*
* Preserve every field produced by earlier pipeline stages.
*/
$result = $analysis;
/*
* Safely convert scalar values to strings.
*/
$toString = static function ($value): string {
if (is_string($value)) {
return $value;
}
if (
is_int($value)
|| is_float($value)
|| is_bool($value)
) {
return (string) $value;
}
return '';
};
/*
* Safely preserve an array.
*/
$toArray = static function ($value): array {
return is_array($value)
? $value
: array();
};
/*
* Safely convert a value to a non-negative integer.
*
* Returns null when the value is not numeric so that a fallback
* can be calculated.
*/
$toNonNegativeInt = static function ($value): ?int {
if (is_int($value)) {
return $value >= 0
? $value
: null;
}
if (
is_string($value)
&& $value !== ''
&& ctype_digit($value)
) {
return (int) $value;
}
if (
is_float($value)
&& $value >= 0
&& floor($value) === $value
) {
return (int) $value;
}
return null;
};
/*
* Count Unicode characters safely.
*
* Arabic diacritics are counted as separate characters. This
* matches the current token-length and marked-stem-length output.
*/
$unicodeLength = static function (string $value): int {
if ($value === '') {
return 0;
}
if (function_exists('mb_strlen')) {
return mb_strlen($value, 'UTF-8');
}
/*
* Fallback when mbstring is unavailable.
*/
$matched = preg_match_all('/./us', $value, $characters);
return $matched !== false
? $matched
: 0;
};
/*
* Original token information.
*/
$result['token'] = $toString(
$analysis['token'] ?? ''
);
$result['matched_suffix'] = $toString(
$analysis['matched_suffix'] ?? ''
);
$result['marked_stem'] = $toString(
$analysis['marked_stem'] ?? ''
);
$result['plain_stem'] = $toString(
$analysis['plain_stem'] ?? ''
);
$result['expanded_skeleton'] = $toString(
$analysis['expanded_skeleton'] ?? ''
);
/*
* Preserve useful suffix metadata.
*/
if (array_key_exists('matched_suffix_entry', $analysis)) {
$result['matched_suffix_entry'] =
$analysis['matched_suffix_entry'];
}
/*
* Preserve arrays before calculating structural lengths.
*/
$result['letters'] = $toArray(
$analysis['letters'] ?? array()
);
$result['structural_clusters'] = $toArray(
$analysis['structural_clusters'] ?? array()
);
if (
isset($analysis['clusters'])
&& is_array($analysis['clusters'])
) {
$result['clusters'] = $analysis['clusters'];
} else {
$result['clusters'] =
$result['structural_clusters'];
}
/*
* ========================================================
* Length measurements
* ========================================================
*/
/*
* Complete token length, including Arabic diacritics.
*/
$tokenLength = $toNonNegativeInt(
$analysis['token_length'] ?? null
);
if ($tokenLength === null) {
$tokenLength = $unicodeLength(
$result['token']
);
}
$result['token_length'] = $tokenLength;
/*
* Matched suffix length, including Arabic diacritics.
*/
$matchedSuffixLength = $toNonNegativeInt(
$analysis['matched_suffix_length'] ?? null
);
if ($matchedSuffixLength === null) {
$matchedSuffixLength = $unicodeLength(
$result['matched_suffix']
);
}
$result['matched_suffix_length'] =
$matchedSuffixLength;
/*
* Raw stem length after suffix removal.
*
* Prefer the value produced by the suffix-removal stage because
* that stage knows the exact retained marked stem.
*/
$stemLengthAfterSuffix = $toNonNegativeInt(
$analysis['token_length_after_suffix_removal']
?? null
);
/*
* Support possible alternative names used by test pages.
*/
if ($stemLengthAfterSuffix === null) {
$stemLengthAfterSuffix = $toNonNegativeInt(
$analysis['length_after_suffix_removal']
?? null
);
}
if ($stemLengthAfterSuffix === null) {
$stemLengthAfterSuffix = $unicodeLength(
$result['marked_stem']
);
}
$result['token_length_after_suffix_removal'] =
$stemLengthAfterSuffix;
/*
* Explicit alias describing what this value measures.
*
* This avoids ambiguity in downstream functions and reports.
*/
$result['marked_stem_length'] =
$stemLengthAfterSuffix;
/*
* Plain stem length after Arabic marks have been removed.
*/
$plainStemLength = $toNonNegativeInt(
$analysis['plain_stem_length'] ?? null
);
if ($plainStemLength === null) {
$plainStemLength = $unicodeLength(
$result['plain_stem']
);
}
$result['plain_stem_length'] =
$plainStemLength;
/*
* Expanded skeleton length.
*
* This can differ from plain_stem_length when shadda or another
* structural feature has been expanded into repeated letters.
*/
$expandedSkeletonLength = $toNonNegativeInt(
$analysis['expanded_skeleton_length'] ?? null
);
if ($expandedSkeletonLength === null) {
$expandedSkeletonLength = $unicodeLength(
$result['expanded_skeleton']
);
}
$result['expanded_skeleton_length'] =
$expandedSkeletonLength;
/*
* Number of extracted structural letters.
*
* Prefer the letters array because it represents the output of
* the structural extraction stage.
*/
if (count($result['letters']) > 0) {
$structuralLetterCount =
count($result['letters']);
} else {
$structuralLetterCount =
$expandedSkeletonLength;
}
$result['structural_letter_count'] =
$structuralLetterCount;
/*
* A normalized length profile can be passed intact to later
* functions without requiring them to resolve field aliases.
*/
$result['stem_length_profile'] = array(
'token_length' =>
$tokenLength,
'matched_suffix_length' =>
$matchedSuffixLength,
'marked_stem_length' =>
$stemLengthAfterSuffix,
'plain_stem_length' =>
$plainStemLength,
'expanded_skeleton_length' =>
$expandedSkeletonLength,
'structural_letter_count' =>
$structuralLetterCount,
);
/*
* Length information is considered available when a stem exists
* in any normalized representation.
*/
$result['has_stem_length'] =
$stemLengthAfterSuffix > 0
|| $plainStemLength > 0
|| $expandedSkeletonLength > 0;
/*
* Basic arithmetic consistency check.
*
* This does not invalidate the analysis. It simply exposes
* disagreements between the suffix stage and the supplied
* lengths for debugging.
*/
$expectedMarkedStemLength =
max(0, $tokenLength - $matchedSuffixLength);
$result['expected_length_after_suffix_removal'] =
$expectedMarkedStemLength;
$result['stem_length_is_consistent'] =
$stemLengthAfterSuffix ===
$expectedMarkedStemLength;
/*
* Structural positions.
*/
$result['vn'] = $toString(
$analysis['vn'] ?? ''
);
$result['vm'] = $toString(
$analysis['vm'] ?? ''
);
$result['fs'] = $toString(
$analysis['fs'] ?? ''
);
/*
* Root framework.
*/
$result['r0'] = $toString(
$analysis['r0'] ?? ''
);
$result['v1'] = $toString(
$analysis['v1'] ?? ''
);
$result['r1'] = $toString(
$analysis['r1'] ?? ''
);
$result['v2'] = $toString(
$analysis['v2'] ?? ''
);
$result['r2'] = $toString(
$analysis['r2'] ?? ''
);
/*
* Repetition flags.
*/
$result['r1_repeat'] = isset(
$analysis['r1_repeat']
)
? (bool) $analysis['r1_repeat']
: false;
$result['r2_repeat'] = isset(
$analysis['r2_repeat']
)
? (bool) $analysis['r2_repeat']
: false;
/*
* Radical marks.
*/
$result['r0_marks'] = $toString(
$analysis['r0_marks'] ?? ''
);
$result['r1_marks'] = $toString(
$analysis['r1_marks'] ?? ''
);
$result['r2_marks'] = $toString(
$analysis['r2_marks'] ?? ''
);
/*
* Form information.
*
* Preserve an earlier selected form. This function does not
* independently infer a form from length.
*/
$form = '';
if (isset($analysis['form'])) {
$form = $toString(
$analysis['form']
);
}
if (
$form === ''
&& isset($analysis['detected_form'])
) {
$form = $toString(
$analysis['detected_form']
);
}
$result['form'] = $form;
$result['detected_form'] = $form;
/*
* Construct the root only when calculated radical values exist.
*/
$rootLetters = array();
foreach (
array('r0', 'r1', 'r2')
as $position
) {
$letter = $toString(
$result[$position] ?? ''
);
if ($letter !== '') {
$rootLetters[] = $letter;
}
}
/*
* Do not erase an earlier valid root when the radical stage has
* not produced any positions.
*/
if (count($rootLetters) > 0) {
$result['root_letters'] =
$rootLetters;
$result['root'] =
implode('', $rootLetters);
} else {
$result['root_letters'] = $toArray(
$analysis['root_letters'] ?? array()
);
$result['root'] = $toString(
$analysis['root'] ?? ''
);
}
return $result;
}
/**
* ============================================================
* FUNCTION 10
* Extract Form Sign
* ============================================================
*
* Preserves and normalizes an fs value assigned by an earlier
* structural extraction stage.
*
* This function does not independently detect the form sign.
*/
function ace_extract_form_sign(array $analysis): array
{
/*
* Do not overwrite a valid form sign.
*/
if (
isset($analysis['fs'])
&& (
is_string($analysis['fs'])
|| is_int($analysis['fs'])
|| is_float($analysis['fs'])
)
) {
$analysis['fs'] = (string) $analysis['fs'];
} else {
$analysis['fs'] = '';
}
return $analysis;
}
/**
* Return the first short vowel attached to a cluster.
*
* Recognized vowels:
* َ fatha
* ُ damma
* ِ kasra
*/
function ace_extract_stem_slot_vowel(
array $cluster
): string {
$marksValue = $cluster['marks'] ?? [];
if (is_array($marksValue)) {
$marks = $marksValue;
} elseif (is_string($marksValue)) {
/*
* Support a marks value such as:
*
* َ
* َّ
* ُْ
*/
$marks = ace_chars($marksValue);
} else {
$marks = [];
}
foreach ($marks as $mark) {
if (!is_string($mark)) {
continue;
}
if (
$mark === "\u{064E}" // َ fatha
|| $mark === "\u{064F}" // ُ damma
|| $mark === "\u{0650}" // ِ kasra
) {
return $mark;
}
}
return '';
}
function ace_extract_stem_slots(
$input,
array $analysis = array()
): array {
/*
* Supported calling styles:
*
* ace_extract_stem_slots($analysis)
* ace_extract_stem_slots($markedStem, $analysis)
*/
if (is_array($input)) {
$result = $input;
$markedStem = (
isset($result['marked_stem'])
&& is_string($result['marked_stem'])
)
? $result['marked_stem']
: '';
} else {
$result = $analysis;
$markedStem = is_string($input)
? $input
: '';
}
/*
* Reset only values owned by this stage.
*/
$result = array_replace(
$result,
array(
'vn' => '',
'vm' => '',
'fs' => '',
/*
* Noun/participle prefix.
*
* Example:
* مُسْتَدْرِسُونَ
*
* np = م
*/
'np' => '',
'r0' => '',
'v1' => '',
'r1' => '',
'v2' => '',
'r2' => '',
'r1_repeat' => false,
'r2_repeat' => false,
'r0_marks' => '',
'r1_marks' => '',
'r2_marks' => '',
'initial_onset' => '',
'structural_clusters' => array(),
'root_cluster_indexes' => array(),
)
);
$result['marked_stem'] = $markedStem;
if ($markedStem === '') {
return $result;
}
/*
* Build letter-plus-mark clusters.
*/
$rawClusters = ace_stem_position_make_clusters(
$markedStem
);
if (!is_array($rawClusters)) {
return $result;
}
/*
* Normalize cluster structure.
*/
$clusters = array();
foreach ($rawClusters as $cluster) {
if (
!is_array($cluster)
|| !isset($cluster['letter'])
|| !is_string($cluster['letter'])
|| $cluster['letter'] === ''
) {
continue;
}
$letter = $cluster['letter'];
/*
* Support:
*
* 'marks' => 'َ'
*
* and:
*
* 'marks' => array('َ', 'ّ')
*/
$marksValue = isset($cluster['marks'])
? $cluster['marks']
: '';
if (is_array($marksValue)) {
$marks = implode(
'',
array_map(
static function ($mark): string {
return is_string($mark)
? $mark
: '';
},
$marksValue
)
);
} elseif (is_string($marksValue)) {
$marks = $marksValue;
} else {
$marks = '';
}
$text = (
isset($cluster['text'])
&& is_string($cluster['text'])
)
? $cluster['text']
: $letter . $marks;
$clusters[] = array(
'letter' => $letter,
'marks' => $marks,
'text' => $text,
);
}
$result['structural_clusters'] = $clusters;
if ($clusters === array()) {
return $result;
}
$letters = array_map(
static function (array $cluster): string {
return isset($cluster['letter'])
? (string) $cluster['letter']
: '';
},
$clusters
);
$clusterCount = count($clusters);
$result['initial_onset'] = isset($letters[0])
? $letters[0]
: '';
/*
* Index at which the root-bearing portion begins.
*/
$rootStart = 0;
/*
* Structural form flags used when selecting radicals.
*/
$isFormX = false;
$isFormVOrVI = false;
$isFormVII = false;
$isFormVIII = false;
/*
* ------------------------------------------------------------
* Form X: imperfect
*
* ي + ست + root
* ت + ست + root
* أ + ست + root
* ن + ست + root
*
* Examples:
*
* يَسْتَدْرِسُ
* أَسْتَدْرِسُ
*
* The surface sequence is يست / تست / أست / نست, but the
* form-sign value remains است for consistent form detection.
* ------------------------------------------------------------
*/
if (
$clusterCount >= 6
&& in_array(
isset($letters[0]) ? $letters[0] : '',
array('ي', 'ت', 'أ', 'ن'),
true
)
&& isset($letters[1], $letters[2])
&& $letters[1] === 'س'
&& $letters[2] === 'ت'
) {
$result['vn'] = $letters[0];
$result['fs'] = 'است';
$rootStart = 3;
$isFormX = true;
}
/*
* ------------------------------------------------------------
* Form X: active/passive participle
*
* م + ست + root
*
* Example:
*
* مُسْتَدْرِسُونَ
*
* np records the participial م.
* ------------------------------------------------------------
*/
elseif (
$clusterCount >= 6
&& isset($letters[0], $letters[1], $letters[2])
&& $letters[0] === 'م'
&& $letters[1] === 'س'
&& $letters[2] === 'ت'
) {
$result['np'] = 'م';
$result['fs'] = 'است';
$rootStart = 3;
$isFormX = true;
}
/*
* ------------------------------------------------------------
* Form X: perfect, imperative, or verbal noun
*
* ا + س + ت + root
*
* Examples:
*
* اِسْتَدْرَسْتُمْ
* اِسْتِدْرَاسٌ
*
* Hamzated spellings are accepted defensively.
* ------------------------------------------------------------
*/
elseif (
$clusterCount >= 6
&& isset($letters[0], $letters[1], $letters[2])
&& in_array(
$letters[0],
array('ا', 'إ', 'أ'),
true
)
&& $letters[1] === 'س'
&& $letters[2] === 'ت'
) {
$result['fs'] = 'است';
$rootStart = 3;
$isFormX = true;
}
/*
* ------------------------------------------------------------
* Forms V and VI imperfect onset
*
* imperfect prefix + derivational ت + root
*
* Examples:
*
* يَتَدَرَّسُ
* يُتَدَرَّسُ
* يَتَدَارَسُ
*
* vn = ي
* vm = ت
* ------------------------------------------------------------
*/
elseif (
$clusterCount >= 5
&& in_array(
isset($letters[0]) ? $letters[0] : '',
array('ي', 'ت', 'أ', 'ن'),
true
)
&& isset($letters[1])
&& $letters[1] === 'ت'
) {
$result['vn'] = $letters[0];
$result['vm'] = 'ت';
$rootStart = 2;
$isFormVOrVI = true;
}
/*
* ------------------------------------------------------------
* Ordinary imperfect onset
*
* ي / ت / أ / ن + root
*
* A minimum of three clusters must remain.
* ------------------------------------------------------------
*/
elseif (
$clusterCount >= 4
&& in_array(
isset($letters[0]) ? $letters[0] : '',
array('ي', 'ت', 'أ', 'ن'),
true
)
) {
$result['vn'] = $letters[0];
$rootStart = 1;
}
/*
* ------------------------------------------------------------
* Initial alif of an imperative or derived form.
* ------------------------------------------------------------
*/
elseif (
$clusterCount >= 4
&& in_array(
isset($letters[0]) ? $letters[0] : '',
array('ا', 'إ', 'أ'),
true
)
) {
$result['vn'] = $letters[0];
$rootStart = 1;
}
$remainingCount = $clusterCount - $rootStart;
/*
* ------------------------------------------------------------
* Form VII
*
* ن + root
*
* Examples:
*
* اِنْدَرَسَ
* يَنْدَرِسُ
*
* For an imperfect, the initial ي/ت/أ/ن has already been
* recorded as vn.
* ------------------------------------------------------------
*/
if (
!$isFormX
&& $remainingCount >= 4
&& isset($letters[$rootStart])
&& $letters[$rootStart] === 'ن'
) {
$result['fs'] = 'ن';
$rootStart++;
$remainingCount--;
$isFormVII = true;
}
/*
* ------------------------------------------------------------
* Determine root-cluster indexes.
* ------------------------------------------------------------
*/
$rootIndexes = array();
/*
* Form VIII:
*
* r0 + ت + r1 + r2
*
* Examples:
*
* اِكْتَتَبَ
* يَكْتَتِبُ
*
* Do not apply this rule to Form X, because its ت belongs
* to the است signature and has already been handled.
* ------------------------------------------------------------
*/
if (
!$isFormX
&& !$isFormVOrVI
&& !$isFormVII
&& $remainingCount >= 4
&& isset($letters[$rootStart + 1])
&& $letters[$rootStart + 1] === 'ت'
) {
$result['vm'] = 'ت';
$rootIndexes = array(
$rootStart,
$rootStart + 2,
$rootStart + 3,
);
$isFormVIII = true;
}
/*
* ------------------------------------------------------------
* Long alif after r0
*
* Form III or Form VI structure:
*
* r0 + ا + r1 + r2
*
* Example:
*
* يَتَدَارَسُ
*
* d + ā + r + s
*
* Root indexes:
*
* d, r, s
* ------------------------------------------------------------
*/
elseif (
$remainingCount >= 4
&& isset(
$letters[$rootStart],
$letters[$rootStart + 1],
$letters[$rootStart + 2],
$letters[$rootStart + 3]
)
&& in_array(
$letters[$rootStart + 1],
array('ا', 'ى'),
true
)
) {
$rootIndexes = array(
$rootStart,
$rootStart + 2,
$rootStart + 3,
);
}
/*
* ------------------------------------------------------------
* Long alif after r1
*
* Common verbal-noun structure:
*
* r0 + r1 + ا + r2
*
* Example:
*
* اِسْتِدْرَاسٌ
*
* d + r + ā + s
*
* Root indexes:
*
* d, r, s
* ------------------------------------------------------------
*/
elseif (
$remainingCount >= 4
&& isset(
$letters[$rootStart],
$letters[$rootStart + 1],
$letters[$rootStart + 2],
$letters[$rootStart + 3]
)
&& in_array(
$letters[$rootStart + 2],
array('ا', 'ى'),
true
)
) {
$rootIndexes = array(
$rootStart,
$rootStart + 1,
$rootStart + 3,
);
}
/*
* ------------------------------------------------------------
* Ordinary radical sequence.
*
* Select only the first three root-bearing clusters.
*
* This intentionally ignores unresolved suffix clusters after
* the third radical. Therefore:
*
* يَسْتَدْرِسُونَ
*
* becomes:
*
* ي + ست + د ر س + ون
*
* and only د ر س are assigned as radicals.
*
* Similarly:
*
* اِسْتَدْرَسْتُمْ
*
* becomes:
*
* است + د ر س + تم
*
* and only د ر س are assigned.
* ------------------------------------------------------------
*/
else {
for (
$index = $rootStart;
$index < $clusterCount
&& count($rootIndexes) < 3;
$index++
) {
$rootIndexes[] = $index;
}
}
$result['root_cluster_indexes'] = $rootIndexes;
/*
* Safely retrieve root clusters.
*/
$r0Cluster = (
isset($rootIndexes[0])
&& isset($clusters[$rootIndexes[0]])
)
? $clusters[$rootIndexes[0]]
: null;
$r1Cluster = (
isset($rootIndexes[1])
&& isset($clusters[$rootIndexes[1]])
)
? $clusters[$rootIndexes[1]]
: null;
$r2Cluster = (
isset($rootIndexes[2])
&& isset($clusters[$rootIndexes[2]])
)
? $clusters[$rootIndexes[2]]
: null;
/*
* Assign r0 and v1.
*/
if (is_array($r0Cluster)) {
$result['r0'] = isset($r0Cluster['letter'])
? (string) $r0Cluster['letter']
: '';
$result['r0_marks'] = isset($r0Cluster['marks'])
? (string) $r0Cluster['marks']
: '';
$result['v1'] = ace_extract_stem_slot_vowel(
$r0Cluster
);
}
/*
* Assign r1 and v2.
*/
if (is_array($r1Cluster)) {
$result['r1'] = isset($r1Cluster['letter'])
? (string) $r1Cluster['letter']
: '';
$result['r1_marks'] = isset($r1Cluster['marks'])
? (string) $r1Cluster['marks']
: '';
$result['v2'] = ace_extract_stem_slot_vowel(
$r1Cluster
);
/*
* strpos() is used instead of str_contains() for
* compatibility with PHP versions before PHP 8.
*/
$result['r1_repeat'] = (
strpos($result['r1_marks'], 'ّ') !== false
);
}
/*
* Assign r2.
*/
if (is_array($r2Cluster)) {
$result['r2'] = isset($r2Cluster['letter'])
? (string) $r2Cluster['letter']
: '';
$result['r2_marks'] = isset($r2Cluster['marks'])
? (string) $r2Cluster['marks']
: '';
$result['r2_repeat'] = (
strpos($result['r2_marks'], 'ّ') !== false
);
}
return $result;
}
function ace_extract_comparative_root(array $clusters): array
{
// Expected clusters:
// 0 = أَ
// 1 = r0ْ
// 2 = r1َ
// 3 = r2
if (
count($clusters) === 4 &&
($clusters[0]['letter'] ?? '') === 'أ'
) {
return [
'r0' => $clusters[1]['letter'] ?? '',
'r1' => $clusters[2]['letter'] ?? '',
'r2' => $clusters[3]['letter'] ?? '',
'root' => implode('', [
$clusters[1]['letter'] ?? '',
$clusters[2]['letter'] ?? '',
$clusters[3]['letter'] ?? '',
]),
];
}
return [];
}
function ace_normalize_radical(string $letter): string
{
$hamzaForms = [
'أ',
'إ',
'ؤ',
'ئ',
'ء',
'آ',
];
if (in_array($letter, $hamzaForms, true)) {
return 'ء';
}
return $letter;
}
/**
* Safely converts analyzer values to displayable text.
* Arrays are recursively flattened without producing
* "Array to string conversion" warnings.
*/
function ace_value_to_string(mixed $value, string $separator = ''): string
{
if ($value === null) {
return '';
}
if (is_bool($value)) {
return $value ? 'true' : 'false';
}
if (is_scalar($value)) {
return (string) $value;
}
if (is_array($value)) {
$parts = [];
foreach ($value as $item) {
$text = ace_value_to_string($item, $separator);
if ($text !== '') {
$parts[] = $text;
}
}
return implode($separator, $parts);
}
return '';
}
/**
* ============================================================
* FUNCTION 1
* Extract stem positions
* ============================================================
*
* Accepts either:
* - a complete analysis array containing marked_stem; or
* - a marked-stem string.
*
* Two length systems are maintained:
*
* 1. Marked-unit length
*
* Every Arabic base letter and every attached mark occupies
* one unit.
*
* دَرَّس = 6 units
* تَدَرَّس = 8 units
* يَتَدَرَّس = 10 units
*
* 2. Structural-position length
*
* Every base-letter cluster occupies one position and every
* shadda contributes one additional consonantal position.
*
* دَرَّس = 3 clusters + 1 shadda = 4
* تَدَرَّس = 4 clusters + 1 shadda = 5
* يَتَدَرَّس = 5 clusters + 1 shadda = 6
*
* Marked-unit length is used for compatibility with the
* dedicated effective-length stage. Structural-position length
* is used when deciding whether a doubled triliteral structure
* is actually supported.
*
* @param string|array<string, mixed> $input
*
* @return array<string, mixed>
*/
function ace_extract_stem_position($input): array
{
/*
* Preserve all upstream pipeline information.
*/
$result = is_array($input)
? $input
: array();
/*
* Resolve marked_stem.
*/
if (is_string($input)) {
$markedStem = $input;
} elseif (
isset($result['marked_stem'])
&& is_string($result['marked_stem'])
) {
$markedStem = $result['marked_stem'];
} else {
$markedStem = '';
}
/*
* Preserve length fields before stage-owned defaults are
* applied.
*/
$upstreamSurfaceLength =
isset($result['stem_surface_length'])
&& is_numeric($result['stem_surface_length'])
? (int) $result['stem_surface_length']
: null;
$upstreamShaddaCount =
isset($result['stem_shadda_count'])
&& is_numeric($result['stem_shadda_count'])
? (int) $result['stem_shadda_count']
: null;
$upstreamEffectiveLength =
isset($result['stem_effective_length'])
&& is_numeric($result['stem_effective_length'])
? (int) $result['stem_effective_length']
: null;
/*
* Reset only fields owned by this stage.
*/
$result = array_replace(
$result,
ace_stem_position_defaults()
);
$result['marked_stem'] = $markedStem;
/*
* Diagnostics and structural metadata.
*/
$result['medial_shadda_detected'] = false;
$result['medial_shadda_index'] = null;
$result['medial_shadda_length_supported'] = false;
$result['stem_position_length_source'] = '';
$result['stem_position_effective_length'] = 0;
$result['stem_structural_position_length'] = 0;
$result['radical_indexes'] = array();
$result['skip_indexes'] = array();
$result['form_x_prefix_detected'] = false;
$result['imperfect_prefix_detected'] = false;
$result['derivational_t_detected'] = false;
if ($markedStem === '') {
return $result;
}
/*
* Build one cluster per base letter.
*/
$clusters = ace_stem_position_make_clusters(
$markedStem
);
if (!is_array($clusters)) {
$clusters = array();
}
$result['clusters'] = $clusters;
$result['structural_clusters'] = $clusters;
if ($clusters === array()) {
return $result;
}
$clusterCount = count($clusters);
/*
* Read a sequence of cluster letters without marks.
*/
$readLetters = static function (
array $sourceClusters,
int $start,
int $length
): string {
if ($start < 0 || $length <= 0) {
return '';
}
$letters = '';
$end = min(
count($sourceClusters),
$start + $length
);
for ($i = $start; $i < $end; $i++) {
$letters .=
ace_stem_position_cluster_letter(
$sourceClusters,
$i
);
}
return $letters;
};
/*
* --------------------------------------------------------
* Calculate both length systems
* --------------------------------------------------------
*/
$localShaddaCount = 0;
foreach ($clusters as $clusterIndex => $cluster) {
$marks =
ace_stem_position_cluster_marks(
$clusters,
(int) $clusterIndex
);
if (ace_stem_position_has_shadda($marks)) {
$localShaddaCount++;
}
}
/*
* Number of written base-letter clusters.
*/
$localSurfaceLength = $clusterCount;
/*
* Consonantal structural positions. Shadda contributes one
* additional position.
*/
$localStructuralPositionLength =
$clusterCount + $localShaddaCount;
/*
* Marked-unit length. This corresponds to values such as:
*
* دَرَّس = 6
* تَدَرَّس = 8
* يَتَدَرَّس = 10
*/
$localMarkedUnitLength =
function_exists('mb_strlen')
? mb_strlen($markedStem, 'UTF-8')
: strlen($markedStem);
/*
* Prefer the dedicated upstream effective-length result.
*/
if (
$upstreamEffectiveLength !== null
&& $upstreamEffectiveLength > 0
) {
$markedEffectiveLength =
$upstreamEffectiveLength;
$result['stem_position_length_source'] =
'stem_effective_length';
} else {
$markedEffectiveLength =
$localMarkedUnitLength;
$result['stem_position_length_source'] =
'marked_stem_fallback';
}
$result['stem_surface_length'] =
$upstreamSurfaceLength !== null
&& $upstreamSurfaceLength >= 0
? $upstreamSurfaceLength
: $localSurfaceLength;
$result['stem_shadda_count'] =
$upstreamShaddaCount !== null
&& $upstreamShaddaCount >= 0
? $upstreamShaddaCount
: $localShaddaCount;
$result['stem_effective_length'] =
$markedEffectiveLength;
$result['stem_position_effective_length'] =
$markedEffectiveLength;
$result['stem_structural_position_length'] =
$localStructuralPositionLength;
/*
* --------------------------------------------------------
* Determine grammatical context
* --------------------------------------------------------
*/
$lengthContext =
isset($result['stem_length_context'])
&& is_string($result['stem_length_context'])
? strtolower(trim(
$result['stem_length_context']
))
: '';
$matchedSuffixEntry =
isset($result['matched_suffix_entry'])
&& is_string($result['matched_suffix_entry'])
? strtolower(
$result['matched_suffix_entry']
)
: '';
$matchedFamily =
isset($result['matched_family'])
&& is_string($result['matched_family'])
? strtolower(trim(
$result['matched_family']
))
: '';
$looksImperfect =
strpos($lengthContext, 'imperfect') !== false
|| strpos(
$matchedSuffixEntry,
'families=imperfect'
) !== false
|| $matchedFamily === 'imperfect'
|| $matchedFamily === 'subjunctive'
|| $matchedFamily === 'jussive';
/*
* Read frequently used initial sequences.
*/
$firstLetter = ace_stem_position_cluster_letter(
$clusters,
0
);
$firstTwoLetters = $readLetters(
$clusters,
0,
2
);
$firstThreeLetters = $readLetters(
$clusters,
0,
3
);
$secondAndThirdLetters = $readLetters(
$clusters,
1,
2
);
/*
* --------------------------------------------------------
* Consume high-confidence prefixes
* --------------------------------------------------------
*/
$workingIndex = 0;
$consumedIndexes = array();
/*
* Perfect, imperative, and verbal-noun Form X:
*
* است + r0 + r1 + r2
*/
$perfectFormX =
$clusterCount >= 6
&& $firstThreeLetters === 'است';
/*
* Imperfect Form X:
*
* ي + ست + r0 + r1 + r2
* ت + ست + r0 + r1 + r2
* أ + ست + r0 + r1 + r2
* ن + ست + r0 + r1 + r2
*
* The initial person marker replaces the initial alif of
* the perfect/imperative است sign.
*/
$imperfectFormX =
$looksImperfect
&& $clusterCount >= 6
&& in_array(
$firstLetter,
array('ي', 'ت', 'أ', 'ن'),
true
)
&& $secondAndThirdLetters === 'ست';
if ($perfectFormX) {
/*
* Preserve the marked surface prefix.
*/
$result['fs'] =
ace_stem_position_join_clusters(
$clusters,
0,
3
);
$result['form_x_prefix_detected'] = true;
$workingIndex = 3;
$consumedIndexes = array(0, 1, 2);
} elseif ($imperfectFormX) {
$result['vn'] =
ace_stem_position_cluster_text(
$clusters,
0
);
/*
* Publish the canonical Form X sign. Downstream form
* detection can therefore use one value for perfect and
* imperfect Form X.
*/
$result['fs'] = 'است';
$result['form_x_prefix_detected'] = true;
$result['imperfect_prefix_detected'] = true;
$workingIndex = 3;
$consumedIndexes = array(0, 1, 2);
} else {
/*
* Ordinary imperfect person marker.
*
* Require at least three surface clusters after it.
*/
$canReserveImperfectMarker =
$looksImperfect
&& $clusterCount >= 4
&& in_array(
$firstLetter,
array('ي', 'ت', 'أ', 'ن'),
true
);
if ($canReserveImperfectMarker) {
$result['vn'] =
ace_stem_position_cluster_text(
$clusters,
0
);
$result['imperfect_prefix_detected'] = true;
$workingIndex = 1;
$consumedIndexes[] = 0;
}
}
/*
* --------------------------------------------------------
* Locate medial shadda
* --------------------------------------------------------
*
* A medial shadda represents doubled r1 only when:
*
* - there is a preceding radical;
* - there is a following radical;
* - enough structural positions remain;
* - the shadda is not in a consumed prefix.
*/
$medialShaddaIndexes = array();
for (
$i = max(1, $workingIndex + 1);
$i < $clusterCount - 1;
$i++
) {
$marks =
ace_stem_position_cluster_marks(
$clusters,
$i
);
if (!ace_stem_position_has_shadda($marks)) {
continue;
}
$r0CandidateIndex = $i - 1;
$r1CandidateIndex = $i;
$r2CandidateIndex = $i + 1;
if (
in_array(
$r0CandidateIndex,
$consumedIndexes,
true
)
|| in_array(
$r1CandidateIndex,
$consumedIndexes,
true
)
|| in_array(
$r2CandidateIndex,
$consumedIndexes,
true
)
) {
continue;
}
$r0Candidate =
ace_stem_position_cluster_letter(
$clusters,
$r0CandidateIndex
);
$r1Candidate =
ace_stem_position_cluster_letter(
$clusters,
$r1CandidateIndex
);
$r2Candidate =
ace_stem_position_cluster_letter(
$clusters,
$r2CandidateIndex
);
if (
$r0Candidate === ''
|| $r1Candidate === ''
|| $r2Candidate === ''
) {
continue;
}
/*
* A doubled triliteral requires four structural
* consonantal positions:
*
* r0 + r1 + repeated-r1 + r2
*/
$prefixPositionCount =
max(
0,
$r0CandidateIndex - $workingIndex
);
$requiredStructuralLength =
$workingIndex
+ $prefixPositionCount
+ 4;
if (
$localStructuralPositionLength
< $requiredStructuralLength
) {
continue;
}
/*
* Once the ordinary imperfect marker has been consumed,
* material before r0 is a derivational sign.
*
* For يَتَدَرَّس:
*
* index 0 = imperfect ي
* index 1 = derivational ت
* index 2 = r0 د
* index 3 = r1 رّ
* index 4 = r2 س
*/
if (
$r0CandidateIndex > $workingIndex
&& $result['fs'] === ''
) {
$result['fs'] =
ace_stem_position_join_clusters(
$clusters,
$workingIndex,
$r0CandidateIndex - $workingIndex
);
for (
$prefixIndex = $workingIndex;
$prefixIndex < $r0CandidateIndex;
$prefixIndex++
) {
$consumedIndexes[] = $prefixIndex;
}
if (
$readLetters(
$clusters,
$workingIndex,
$r0CandidateIndex - $workingIndex
) === 'ت'
) {
$result['derivational_t_detected'] =
true;
}
}
/*
* Perfect Form V:
*
* ت + r0 + r1ّ + r2
*
* When no imperfect marker was consumed, the initial ت
* is the derivational onset and must not become r0.
*/
if (
!$looksImperfect
&& $workingIndex === 0
&& $r0CandidateIndex === 1
&& $firstLetter === 'ت'
) {
$result['vn'] =
ace_stem_position_cluster_text(
$clusters,
0
);
$result['derivational_t_detected'] =
true;
$consumedIndexes[] = 0;
}
$medialShaddaIndexes = array(
$r0CandidateIndex,
$r1CandidateIndex,
$r2CandidateIndex
);
$result['medial_shadda_detected'] = true;
$result['medial_shadda_index'] =
$r1CandidateIndex;
$result['medial_shadda_length_supported'] =
true;
break;
}
/*
* --------------------------------------------------------
* Select radical indexes
* --------------------------------------------------------
*/
if ($medialShaddaIndexes !== array()) {
$radicalIndexes = $medialShaddaIndexes;
} else {
$radicalIndexes =
ace_stem_position_select_radical_indexes(
$clusters,
$workingIndex
);
}
if (!is_array($radicalIndexes)) {
$radicalIndexes = array();
}
/*
* Remove consumed prefix indexes from the general selector.
*/
$filteredIndexes = array();
foreach ($radicalIndexes as $index) {
if (
!is_int($index)
&& !ctype_digit((string) $index)
) {
continue;
}
$index = (int) $index;
if (
$index < 0
|| $index >= $clusterCount
) {
continue;
}
if (
in_array(
$index,
$consumedIndexes,
true
)
) {
continue;
}
if (
in_array(
$index,
$filteredIndexes,
true
)
) {
continue;
}
$filteredIndexes[] = $index;
}
sort($filteredIndexes);
/*
* If the selector returned more than three positions,
* retain the final three unconsumed positions.
*/
if (count($filteredIndexes) > 3) {
$filteredIndexes = array_slice(
$filteredIndexes,
-3
);
}
/*
* Conservative fallback.
*
* Never fabricate a triliteral root from only two surface
* clusters.
*/
if (count($filteredIndexes) < 3) {
$fallbackIndexes = array();
for (
$i = $workingIndex;
$i < $clusterCount;
$i++
) {
if (
in_array(
$i,
$consumedIndexes,
true
)
) {
continue;
}
$fallbackIndexes[] = $i;
}
if (count($fallbackIndexes) >= 3) {
$filteredIndexes = array_slice(
$fallbackIndexes,
-3
);
}
}
$radicalIndexes = $filteredIndexes;
$result['radical_indexes'] =
$radicalIndexes;
/*
* Publish all indexes that downstream stages must not
* reinterpret as radicals.
*/
$consumedIndexes = array_values(
array_unique(
array_map(
'intval',
$consumedIndexes
)
)
);
sort($consumedIndexes);
$result['skip_indexes'] =
$consumedIndexes;
if ($radicalIndexes === array()) {
return $result;
}
/*
* Material between the working start and r0 is a
* provisional form sign.
*/
$firstRadicalIndex =
isset($radicalIndexes[0])
? (int) $radicalIndexes[0]
: null;
if (
$result['fs'] === ''
&& $firstRadicalIndex !== null
&& $firstRadicalIndex > $workingIndex
) {
$result['fs'] =
ace_stem_position_join_clusters(
$clusters,
$workingIndex,
$firstRadicalIndex - $workingIndex
);
for (
$i = $workingIndex;
$i < $firstRadicalIndex;
$i++
) {
if (
!in_array(
$i,
$result['skip_indexes'],
true
)
) {
$result['skip_indexes'][] = $i;
}
}
sort($result['skip_indexes']);
}
/*
* Assign a radical position.
*/
$assignRadical = static function (
array &$target,
array $sourceClusters,
int $radicalNumber,
int $index
): void {
$letter =
ace_stem_position_cluster_letter(
$sourceClusters,
$index
);
$marks =
ace_stem_position_cluster_marks(
$sourceClusters,
$index
);
$radicalKey = 'r' . $radicalNumber;
$indexKey = $radicalKey . '_index';
$marksKey = $radicalKey . '_marks';
$target[$radicalKey] =
ace_stem_position_normalize_radical(
$letter
);
$target[$indexKey] = $index;
$target[$marksKey] = $marks;
};
if (isset($radicalIndexes[0])) {
$assignRadical(
$result,
$clusters,
0,
(int) $radicalIndexes[0]
);
$result['v1'] =
$result['r0_marks'];
}
if (isset($radicalIndexes[1])) {
$assignRadical(
$result,
$clusters,
1,
(int) $radicalIndexes[1]
);
$result['v2'] =
$result['r1_marks'];
/*
* Calculate from the final selected radical rather than
* relying only on the earlier candidate scan.
*/
$result['r1_repeat'] =
ace_stem_position_has_shadda(
(string) $result['r1_marks']
);
}
if (isset($radicalIndexes[2])) {
$assignRadical(
$result,
$clusters,
2,
(int) $radicalIndexes[2]
);
$result['r2_repeat'] =
ace_stem_position_has_shadda(
(string) $result['r2_marks']
);
}
/*
* Build the lexical root.
*
* Shadda represents repetition structurally but does not
* duplicate the radical in the lexical root.
*/
$result['root'] =
(string) $result['r0']
. (string) $result['r1']
. (string) $result['r2'];
return $result;
}
/**
* Determine whether a Unicode character is an Arabic
* combining mark.
*
* @param mixed $character
*
* @return bool
*/
function ace_stem_position_is_mark($character): bool
{
if (
!is_string($character)
|| $character === ''
) {
return false;
}
return preg_match(
'/^[\x{0610}-\x{061A}'
. '\x{064B}-\x{065F}'
. '\x{0670}'
. '\x{06D6}-\x{06ED}]$/u',
$character
) === 1;
}
function ace_stem_position_has_shadda($marks): bool
{
return is_string($marks)
&& $marks !== ''
&& mb_strpos($marks, "\u{0651}", 0, 'UTF-8') !== false;
}
function ace_stem_position_normalize_radical($letter): string
{
if (!is_string($letter)) {
return '';
}
return in_array($letter, ['أ', 'إ', 'ؤ', 'ئ', 'ء', 'آ'], true)
? 'ء'
: $letter;
}
function ace_stem_position_make_clusters($markedStem): array
{
if (!is_string($markedStem) || $markedStem === '') {
return [];
}
$characters = preg_split('//u', $markedStem, -1, PREG_SPLIT_NO_EMPTY);
if (!is_array($characters)) {
return [];
}
$clusters = [];
foreach ($characters as $character) {
if (!is_string($character) || $character === '') {
continue;
}
// Ignore tatweel; it is decorative and has no structural position.
if ($character === "\u{0640}") {
continue;
}
if (ace_stem_position_is_mark($character)) {
$lastIndex = count($clusters) - 1;
if ($lastIndex >= 0) {
$clusters[$lastIndex]['marks'] .= $character;
$clusters[$lastIndex]['text'] .= $character;
}
continue;
}
$clusters[] = [
'letter' => $character,
'marks' => '',
'text' => $character,
];
}
return $clusters;
}
function ace_stem_position_cluster_value(
array $clusters,
int $index,
string $key
): string {
if ($index < 0 || !isset($clusters[$index]) || !is_array($clusters[$index])) {
return '';
}
$value = $clusters[$index][$key] ?? '';
return is_string($value) ? $value : '';
}
function ace_stem_position_cluster_letter(array $clusters, int $index): string
{
return ace_stem_position_cluster_value($clusters, $index, 'letter');
}
function ace_stem_position_cluster_marks(array $clusters, int $index): string
{
return ace_stem_position_cluster_value($clusters, $index, 'marks');
}
function ace_stem_position_cluster_text(array $clusters, int $index): string
{
return ace_stem_position_cluster_value($clusters, $index, 'text');
}
function ace_stem_position_join_clusters(
array $clusters,
int $start,
int $length
): string {
if ($start < 0 || $length <= 0) {
return '';
}
$parts = [];
$end = min(count($clusters), $start + $length);
for ($index = $start; $index < $end; $index++) {
$parts[] = ace_stem_position_cluster_text($clusters, $index);
}
return implode('', $parts);
}
function ace_stem_position_is_unmarked_long_vowel(
array $clusters,
int $index
): bool {
return ace_stem_position_cluster_marks($clusters, $index) === ''
&& in_array(
ace_stem_position_cluster_letter($clusters, $index),
['ا', 'و', 'ي', 'ى'],
true
);
}
function ace_stem_position_select_radical_indexes(
array $clusters,
int $startIndex,
array $excludedIndexes = []
): array {
$availableIndexes = [];
$excludedLookup = array_fill_keys($excludedIndexes, true);
for ($index = max(0, $startIndex); $index < count($clusters); $index++) {
if (isset($excludedLookup[$index])) {
continue;
}
if (ace_stem_position_cluster_letter($clusters, $index) !== '') {
$availableIndexes[] = $index;
}
}
if (count($availableIndexes) <= 3) {
return $availableIndexes;
}
// Prefer consonantal positions when extra derivational long vowels exist.
// Weak-root reconstruction remains the responsibility of later stages.
$consonantIndexes = array_values(array_filter(
$availableIndexes,
static function (int $index) use ($clusters): bool {
return !ace_stem_position_is_unmarked_long_vowel($clusters, $index);
}
));
return count($consonantIndexes) >= 3
? array_slice($consonantIndexes, -3)
: array_slice($availableIndexes, -3);
}
function ace_stem_position_defaults(): array
{
return [
'vn' => '',
'vm' => '',
'fs' => '',
'r0' => '',
'v1' => '',
'r1' => '',
'v2' => '',
'r2' => '',
'r1_repeat' => false,
'r2_repeat' => false,
'r0_marks' => '',
'r1_marks' => '',
'r2_marks' => '',
'clusters' => [],
'structural_clusters' => [],
'radical_indexes' => [],
'root' => '',
];
}
/**
* ============================================================
* FUNCTION 4
* Extract r0
* ============================================================
*
* Extracts the radical immediately preceding r1.
*
* Important index rule:
*
* Structural indexes such as r1_index refer to the unexpanded
* cluster collection. They must not be applied directly to the
* expanded-skeleton letters array, because shadda expansion adds
* an extra letter and changes later indexes.
*
* Source priority:
*
* 1. structural_clusters
* 2. clusters
* 3. letters and marks arrays as a compatibility fallback
*
* Structural positions identified in skip_indexes are ignored.
*
* Adds or recalculates:
*
* - r0
* - r0_index
* - r0_marks
*
* Unrelated analysis fields are preserved.
*
* @param array<string, mixed> $analysis
*
* @return array<string, mixed>
*/
function ace_extract_r0(array $analysis): array
{
/*
* Preserve the complete cumulative analysis.
*/
$result = $analysis;
/*
* Reset only fields owned by this stage.
*/
$result['r0'] = '';
$result['r0_index'] = null;
$result['r0_marks'] = '';
/*
* ------------------------------------------------------------
* Normalize r1_index.
* ------------------------------------------------------------
*/
$r1Index = null;
if (
isset($analysis['r1_index'])
&& is_int($analysis['r1_index'])
) {
$r1Index = $analysis['r1_index'];
} elseif (
isset($analysis['r1_index'])
&& is_string($analysis['r1_index'])
&& ctype_digit($analysis['r1_index'])
) {
$r1Index = (int) $analysis['r1_index'];
} elseif (
isset($analysis['r1_position'])
&& is_int($analysis['r1_position'])
) {
/*
* Compatibility with older field naming.
*/
$r1Index = $analysis['r1_position'];
}
if ($r1Index === null || $r1Index <= 0) {
return $result;
}
/*
* ------------------------------------------------------------
* Normalize indexes that must not become radicals.
* ------------------------------------------------------------
*/
$skipLookup = array();
if (
isset($analysis['skip_indexes'])
&& is_array($analysis['skip_indexes'])
) {
foreach ($analysis['skip_indexes'] as $skipIndex) {
if (is_int($skipIndex) && $skipIndex >= 0) {
$skipLookup[$skipIndex] = true;
continue;
}
if (
is_string($skipIndex)
&& ctype_digit($skipIndex)
) {
$skipLookup[(int) $skipIndex] = true;
}
}
}
/*
* Include individually reported structural positions.
*/
foreach (
array(
'vn_index',
'vn_position',
'vm_index',
'vm_position',
) as $positionField
) {
if (
isset($analysis[$positionField])
&& is_int($analysis[$positionField])
&& $analysis[$positionField] >= 0
) {
$skipLookup[$analysis[$positionField]] = true;
}
}
/*
* Support form-sign indexes when a previous stage supplies
* them as a list.
*/
foreach (
array(
'fs_indexes',
'form_sign_indexes',
) as $indexListField
) {
if (
!isset($analysis[$indexListField])
|| !is_array($analysis[$indexListField])
) {
continue;
}
foreach ($analysis[$indexListField] as $skipIndex) {
if (is_int($skipIndex) && $skipIndex >= 0) {
$skipLookup[$skipIndex] = true;
} elseif (
is_string($skipIndex)
&& ctype_digit($skipIndex)
) {
$skipLookup[(int) $skipIndex] = true;
}
}
}
/*
* Arabic combining marks cannot independently represent a
* radical.
*/
$combiningMarkPattern =
'/^[\x{0610}-\x{061A}'
. '\x{064B}-\x{065F}'
. '\x{0670}'
. '\x{06D6}-\x{06ED}]+$/u';
/*
* Normalize a cluster marks value into one string.
*/
$marksToString = static function ($marks): string {
if (is_string($marks)) {
return $marks;
}
if (!is_array($marks)) {
return '';
}
$output = '';
foreach ($marks as $mark) {
if (is_string($mark)) {
$output .= $mark;
}
}
return $output;
};
/*
* Validate and normalize one possible radical.
*/
$normalizeRadical = static function (
$letter
) use ($combiningMarkPattern): string {
if (
!is_string($letter)
|| $letter === ''
|| $letter === ACE_TATWEEL
|| preg_match(
$combiningMarkPattern,
$letter
) === 1
) {
return '';
}
if (function_exists('ace_normalize_radical')) {
return ace_normalize_radical($letter);
}
return in_array(
$letter,
array('أ', 'إ', 'ؤ', 'ئ', 'آ'),
true
)
? 'ء'
: $letter;
};
/*
* ------------------------------------------------------------
* Preferred source: unexpanded structural clusters.
* ------------------------------------------------------------
*/
$clusters = array();
if (
isset($analysis['structural_clusters'])
&& is_array($analysis['structural_clusters'])
&& $analysis['structural_clusters'] !== array()
) {
$clusters = array_values(
$analysis['structural_clusters']
);
} elseif (
isset($analysis['clusters'])
&& is_array($analysis['clusters'])
&& $analysis['clusters'] !== array()
) {
$clusters = array_values(
$analysis['clusters']
);
}
if ($clusters !== array()) {
/*
* r1_index must belong to this same unexpanded collection.
*/
if (!array_key_exists($r1Index, $clusters)) {
return $result;
}
/*
* Search immediately leftward from r1.
*/
for ($index = $r1Index - 1; $index >= 0; $index--) {
if (isset($skipLookup[$index])) {
continue;
}
if (
!isset($clusters[$index])
|| !is_array($clusters[$index])
) {
continue;
}
$cluster = $clusters[$index];
$letter = isset($cluster['letter'])
&& is_string($cluster['letter'])
? $cluster['letter']
: '';
/*
* Compatibility with clusters that contain only text.
*/
if (
$letter === ''
&& isset($cluster['text'])
&& is_string($cluster['text'])
) {
$plainCluster = function_exists(
'ace_remove_diacritics'
)
? ace_remove_diacritics(
$cluster['text']
)
: preg_replace(
'/[\x{0610}-\x{061A}'
. '\x{064B}-\x{065F}'
. '\x{0670}'
. '\x{06D6}-\x{06ED}]/u',
'',
$cluster['text']
);
$letter = is_string($plainCluster)
? $plainCluster
: '';
}
$radical = $normalizeRadical($letter);
if ($radical === '') {
continue;
}
$result['r0'] = $radical;
$result['r0_index'] = $index;
$result['r0_marks'] = $marksToString(
$cluster['marks'] ?? ''
);
return $result;
}
return $result;
}
/*
* ------------------------------------------------------------
* Compatibility fallback: letters and marks arrays.
* ------------------------------------------------------------
*
* This source should be used only when no cluster collection
* exists. It is unsafe when letters contains expanded shadda
* consonants while r1_index was calculated from clusters.
*/
if (
!isset($analysis['letters'])
|| !is_array($analysis['letters'])
) {
return $result;
}
$letters = array_values($analysis['letters']);
$marks = (
isset($analysis['marks'])
&& is_array($analysis['marks'])
)
? array_values($analysis['marks'])
: array();
if (!array_key_exists($r1Index, $letters)) {
return $result;
}
for ($index = $r1Index - 1; $index >= 0; $index--) {
if (isset($skipLookup[$index])) {
continue;
}
if (!array_key_exists($index, $letters)) {
continue;
}
$radical = $normalizeRadical(
$letters[$index]
);
if ($radical === '') {
continue;
}
$result['r0'] = $radical;
$result['r0_index'] = $index;
$result['r0_marks'] = array_key_exists(
$index,
$marks
)
? $marksToString($marks[$index])
: '';
return $result;
}
return $result;
}
/**
* Assign r2, r1 and r0 by scanning backward from the end of the stem.
*
* Structural long vowels and form markers are not automatically treated
* as radicals. A long ا between two consonants is attached to the vowel
* slot preceding r1, as in Form III and Form VI.
*/
function ace_assign_root_positions_bu(array $analysis): array
{
$clusters = $analysis['structural_clusters']
?? $analysis['clusters']
?? array();
$analysis['r0'] = '';
$analysis['v1'] = '';
$analysis['r1'] = '';
$analysis['v2'] = '';
$analysis['r2'] = '';
$analysis['r0_marks'] = '';
$analysis['r1_marks'] = '';
$analysis['r2_marks'] = '';
if ($clusters === array()) {
return $analysis;
}
/*
* Normalize cluster entries.
*/
$normalized = array();
foreach ($clusters as $index => $cluster) {
if (is_string($cluster)) {
$normalized[] = array(
'index' => $index,
'letter' => $cluster,
'marks' => '',
'text' => $cluster,
);
continue;
}
if (!is_array($cluster)) {
continue;
}
$letter = (string) ($cluster['letter'] ?? '');
$marks = (string) ($cluster['marks'] ?? '');
$text = (string) ($cluster['text'] ?? ($letter . $marks));
if ($letter === '') {
continue;
}
$normalized[] = array(
'index' => $index,
'letter' => $letter,
'marks' => $marks,
'text' => $text,
);
}
if ($normalized === array()) {
return $analysis;
}
$vnIndex = null;
$vmIndex = null;
$fsIndexes = array();
/*
* Locate the imperfect person marker.
*/
if (
isset($normalized[0]) &&
in_array($normalized[0]['letter'], array('ي', 'ت', 'أ', 'ن'), true)
) {
$vnIndex = 0;
$analysis['vn'] = $normalized[0]['letter'];
}
/*
* Locate a structural ت after the imperfect marker.
*
* This covers forms V and VI:
* يَتَدَرَّسُ
* يَتَدَارَسُ
*/
if (
$vnIndex === 0 &&
isset($normalized[1]) &&
$normalized[1]['letter'] === 'ت'
) {
$vmIndex = 1;
$analysis['vm'] = 'ت';
}
/*
* Mark form-sign clusters that must not be selected as radicals.
*/
$fs = (string) ($analysis['fs'] ?? '');
if ($fs !== '') {
$fsLetters = preg_split('//u', $fs, -1, PREG_SPLIT_NO_EMPTY);
$start = $vnIndex === null ? 0 : 1;
foreach ($fsLetters as $fsLetter) {
for ($i = $start, $count = count($normalized); $i < $count; $i++) {
if (
!isset($fsIndexes[$i]) &&
$normalized[$i]['letter'] === $fsLetter
) {
$fsIndexes[$i] = true;
$start = $i + 1;
break;
}
}
}
}
/*
* Scan from the end and collect three radical consonants.
*/
$radicals = array();
$longVowels = array('ا', 'ى', 'و', 'ي');
for ($i = count($normalized) - 1; $i >= 0; $i--) {
$cluster = $normalized[$i];
$letter = $cluster['letter'];
if ($i === $vnIndex || $i === $vmIndex || isset($fsIndexes[$i])) {
continue;
}
/*
* A bare medial long vowel is structural when consonants occur
* on both sides. It must not displace a true radical.
*
* Example:
* ي ت د ا ر س
* ا = long vowel, not r0
*/
if (
in_array($letter, $longVowels, true) &&
$cluster['marks'] === '' &&
ace_is_structural_medial_long_vowel(
$normalized,
$i,
$vnIndex,
$vmIndex,
$fsIndexes
)
) {
continue;
}
$radicals[] = array(
'cluster_index' => $i,
'letter' => ace_normalize_radical($letter),
'marks' => $cluster['marks'],
'text' => $cluster['text'],
);
if (count($radicals) === 3) {
break;
}
}
/*
* Radicals were collected as r2, r1, r0.
*/
if (isset($radicals[0])) {
$analysis['r2'] = $radicals[0]['letter'];
$analysis['r2_marks'] = $radicals[0]['marks'];
}
if (isset($radicals[1])) {
$analysis['r1'] = $radicals[1]['letter'];
$analysis['r1_marks'] = $radicals[1]['marks'];
}
if (isset($radicals[2])) {
$analysis['r0'] = $radicals[2]['letter'];
$analysis['r0_marks'] = $radicals[2]['marks'];
}
/*
* Build vowel spans between the assigned radical positions.
*/
if (isset($radicals[2], $radicals[1])) {
$analysis['v1'] = ace_collect_interradical_material(
$normalized,
$radicals[2]['cluster_index'],
$radicals[1]['cluster_index']
);
}
if (isset($radicals[1], $radicals[0])) {
$analysis['v2'] = ace_collect_interradical_material(
$normalized,
$radicals[1]['cluster_index'],
$radicals[0]['cluster_index']
);
}
$analysis['r1_repeat'] = ace_cluster_has_shadda(
$radicals[1]['marks'] ?? ''
);
$analysis['r2_repeat'] = ace_cluster_has_shadda(
$radicals[0]['marks'] ?? ''
);
$analysis['root'] = implode('', array_filter(
array(
$analysis['r0'],
$analysis['r1'],
$analysis['r2'],
),
static fn(string $value): bool => $value !== ''
));
$analysis['root_letters'] = array_values(array_filter(
array(
$analysis['r0'],
$analysis['r1'],
$analysis['r2'],
),
static fn(string $value): bool => $value !== ''
));
return $analysis;
}
/**
* ============================================================
* FUNCTION 6
* Extract pattern features
* ============================================================
*
* Extracts structural features used for Arabic verb-form
* classification.
*
* This function is intentionally tolerant of failed or partial
* radical extraction, especially with:
*
* - assimilated roots
* - hollow roots
* - defective roots
* - doubly weak roots
* - Form VIII assimilation
*
* It does not modify r0, r1, or r2.
*/
function ace_extract_pattern_features(array $clusters)
{
$features = array(
/*
* Previously extracted prefix positions.
*/
'vn' => '',
'vm' => '',
'fs' => '',
/*
* Radical repetition.
*/
'r1_repeat' => false,
'r2_repeat' => false,
/*
* Long-vowel positions.
*/
'has_long_a_after_r1' => false,
'has_long_a_after_vm' => false,
/*
* Derived-form markers.
*/
'has_form8_t' => false,
'has_form7_n' => false,
'has_ist_prefix' => false,
/*
* Initial hamza.
*/
'starts_with_hamzat_wasl' => false,
'starts_with_hamzat_qat' => false,
);
/*
* --------------------------------------------------------
* 1. Copy previously extracted structural values
* --------------------------------------------------------
*/
foreach (array('vn', 'vm', 'fs') as $key) {
if (
isset($clusters[$key]) &&
is_string($clusters[$key])
) {
$features[$key] = $clusters[$key];
}
}
/*
* --------------------------------------------------------
* 2. Copy radical repetition flags
* --------------------------------------------------------
*
* Important:
* Use the same key names initialized above.
*/
if (array_key_exists('r1_repeat', $clusters)) {
$features['r1_repeat'] =
($clusters['r1_repeat'] === true);
}
if (array_key_exists('r2_repeat', $clusters)) {
$features['r2_repeat'] =
($clusters['r2_repeat'] === true);
}
/*
* --------------------------------------------------------
* 3. Obtain the expanded letter sequence
* --------------------------------------------------------
*
* Prefer "letters" because shadda should already have been
* expanded there:
*
* اِتَّقَى
* ا ت ت ق ى
*
* اِوْقَيَّ
* ا و ق ي ي
*/
$letters = array();
if (
isset($clusters['letters']) &&
is_array($clusters['letters'])
) {
foreach ($clusters['letters'] as $letter) {
if (is_string($letter) && $letter !== '') {
$letters[] = ace_normalize_pattern_letter($letter);
}
}
}
/*
* Fall back to structural clusters when "letters" is absent.
*/
if (
empty($letters) &&
isset($clusters['structural_clusters']) &&
is_array($clusters['structural_clusters'])
) {
foreach ($clusters['structural_clusters'] as $cluster) {
if (!is_array($cluster)) {
continue;
}
if (
isset($cluster['letter']) &&
is_string($cluster['letter']) &&
$cluster['letter'] !== ''
) {
$letter = ace_normalize_pattern_letter(
$cluster['letter']
);
$letters[] = $letter;
/*
* Expand shadda structurally.
*/
$marks = isset($cluster['marks'])
? (string) $cluster['marks']
: '';
if (mb_strpos($marks, 'ّ', 0, 'UTF-8') !== false) {
$letters[] = $letter;
}
}
}
}
if (empty($letters)) {
return $features;
}
$letterCount = count($letters);
$firstLetter = $letters[0];
/*
* --------------------------------------------------------
* 4. Detect the initial hamza type
* --------------------------------------------------------
*
* Bare initial ا is treated as hamzat al-waṣl.
* أ or إ is treated as hamzat al-qaṭʿ.
*/
if ($firstLetter === 'ا') {
$features['starts_with_hamzat_wasl'] = true;
}
if (
$firstLetter === 'أ' ||
$firstLetter === 'إ' ||
$firstLetter === 'ء'
) {
$features['starts_with_hamzat_qat'] = true;
}
/*
* --------------------------------------------------------
* 5. Detect Form X prefix: است
* --------------------------------------------------------
*/
if (
$letterCount >= 3 &&
$letters[0] === 'ا' &&
$letters[1] === 'س' &&
$letters[2] === 'ت'
) {
$features['has_ist_prefix'] = true;
if ($features['fs'] === '') {
$features['fs'] = 'است';
}
}
/*
* --------------------------------------------------------
* 6. Detect Form VII ن marker
* --------------------------------------------------------
*
* Detect from the surface sequence rather than depending
* entirely on fs extraction.
*
* Examples:
*
* اِنْكَتَبَ => ا ن ك ت ب
* اِنْوَقَى => ا ن و ق ى
*
* In اِنْوَقَى, ن must be recognized as the Form VII
* marker even though the root is doubly weak: و ق ي.
*/
if (
$letterCount >= 2 &&
$letters[0] === 'ا' &&
$letters[1] === 'ن'
) {
$features['has_form7_n'] = true;
if ($features['fs'] === '') {
$features['fs'] = 'ن';
}
}
if ($features['fs'] === 'ن' || $features['fs'] === 'نْ') {
$features['has_form7_n'] = true;
}
/*
* --------------------------------------------------------
* 7. Detect Form VIII ت marker
* --------------------------------------------------------
*
* Normal Form VIII:
*
* اِكْتَتَبَ
* ا ك ت ت ب
*
* Weak assimilated Form VIII:
*
* اِتَّقَى
* ا ت ت ق ى
*
* The root is و ق ي, but the initial و has assimilated into
* the Form VIII ت. Therefore the surface sequence starts:
*
* ا + تّ
*
* which expands to:
*
* ا + ت + ت
*/
if ($features['vm'] === 'ت') {
$features['has_form8_t'] = true;
}
/*
* Ordinary Form VIII internal ت.
*
* Initial ا, then first radical, then inserted ت.
*/
if (
$letterCount >= 3 &&
$letters[0] === 'ا' &&
$letters[2] === 'ت'
) {
$features['has_form8_t'] = true;
}
/*
* Assimilated initial-weak Form VIII.
*
* اِتَّقَى => ا ت ت ق ى
*
* The repeated ت represents:
* - assimilated initial و
* - Form VIII inserted ت
*/
if (
$letterCount >= 3 &&
$letters[0] === 'ا' &&
$letters[1] === 'ت' &&
$letters[2] === 'ت'
) {
$features['has_form8_t'] = true;
if ($features['vm'] === '') {
$features['vm'] = 'ت';
}
}
/*
* Other Form VIII assimilation letters.
*
* In roots beginning with د، ذ، ز، ص، ض، ط، ظ، ث,
* the inserted ت may assimilate or change its surface form.
*
* A doubled consonant immediately after initial ا is useful
* evidence for an assimilated Form VIII structure.
*/
$form8AssimilationLetters = array(
'ت', 'ث', 'د', 'ذ', 'ز', 'ص', 'ض', 'ط', 'ظ'
);
if (
$letterCount >= 3 &&
$letters[0] === 'ا' &&
$letters[1] === $letters[2] &&
in_array(
$letters[1],
$form8AssimilationLetters,
true
)
) {
$features['has_form8_t'] = true;
}
/*
* --------------------------------------------------------
* 8. Detect long ا after r1
* --------------------------------------------------------
*
* Prefer explicit indexes when they are reliable.
*/
if (
isset($clusters['r1_index']) &&
is_int($clusters['r1_index'])
) {
$nextIndex = $clusters['r1_index'] + 1;
if (
isset($letters[$nextIndex]) &&
ace_is_long_a_letter($letters[$nextIndex])
) {
$features['has_long_a_after_r1'] = true;
}
}
/*
* Fall back to the radical value when indexes failed.
*/
if (
!$features['has_long_a_after_r1'] &&
isset($clusters['r1']) &&
is_string($clusters['r1']) &&
$clusters['r1'] !== ''
) {
$r1 = ace_normalize_pattern_letter($clusters['r1']);
for ($i = 0; $i < $letterCount - 1; $i++) {
if (
$letters[$i] === $r1 &&
ace_is_long_a_letter($letters[$i + 1])
) {
$features['has_long_a_after_r1'] = true;
break;
}
}
}
/*
* --------------------------------------------------------
* 9. Detect long ا after vm
* --------------------------------------------------------
*/
if ($features['vm'] !== '') {
$vm = ace_normalize_pattern_letter($features['vm']);
for ($i = 0; $i < $letterCount - 1; $i++) {
if (
$letters[$i] === $vm &&
ace_is_long_a_letter($letters[$i + 1])
) {
$features['has_long_a_after_vm'] = true;
break;
}
}
}
return $features;
}
/**
* Normalize a letter for structural pattern comparison.
*
* This normalization is deliberately conservative:
* hamzat al-waṣl and hamzat al-qaṭʿ remain distinguishable.
*/
function ace_normalize_pattern_letter($letter)
{
if (!is_string($letter)) {
return '';
}
/*
* Remove Arabic combining marks if a marked letter was
* accidentally supplied instead of a plain letter.
*/
$letter = preg_replace(
'/[\x{064B}-\x{065F}\x{0670}\x{06D6}-\x{06ED}]/u',
'',
$letter
);
return $letter;
}
/**
* Determine whether a letter represents structural long ā.
*/
function ace_is_long_a_letter($letter)
{
return in_array(
$letter,
array('ا', 'ى', 'ٰ'),
true
);
}
/**
* ============================================================
* FUNCTION 7
* Extract vm
* ============================================================
*
* Extracts the internal derivational consonant associated with
* the first root radical.
*
* Current structural interpretation:
*
* Form VII:
*
* اِنْكَتَبَ
* ا + ن + ك + ت + ب
* vm r0 r1 r2
*
* The marker ن occurs immediately before r0.
*
* Form VIII:
*
* اِكْتَتَبَ
* ا + ك + ت + ت + ب
* r0 vm r1 r2
*
* The Form VIII marker normally occurs immediately after r0.
*
* Recognized Form VIII surface markers:
*
* ت
* ط
* د
*
* Examples:
*
* اِكْتَتَبَ regular ت
* اِضْطَرَبَ assimilated ط
* اِدَّكَرَ assimilated د
*
* Important exclusions:
*
* - Form V and Form VI initial ت belongs to vn.
* - Form X is represented by fs = است or ست.
* - Forms I–IV and IX normally have no vm.
*
* Supported calling styles:
*
* ace_extract_vm($analysis)
* ace_extract_vm($markedStem, $analysis)
*
* Adds or resets:
*
* - vm
* - vm_position
* - vm_surface
* - vm_underlying
*
* @param mixed $input
* @param array<string, mixed> $analysis
*
* @return array<string, mixed>
*/
function ace_extract_vm(
$input,
array $analysis = array()
): array {
/*
* Normalize the supported calling styles.
*/
if (is_array($input)) {
$result = $input;
} else {
$result = $analysis;
if (is_string($input) && $input !== '') {
$result['marked_stem'] = $input;
}
}
/*
* Reset only fields owned by this function.
*/
$result['vm'] = '';
$result['vm_position'] = null;
$result['vm_surface'] = '';
$result['vm_underlying'] = '';
/*
* Obtain the best available cluster list.
*/
if (
isset($result['structural_clusters'])
&& is_array($result['structural_clusters'])
) {
$clusters = array_values(
$result['structural_clusters']
);
} elseif (
isset($result['clusters'])
&& is_array($result['clusters'])
) {
$clusters = array_values(
$result['clusters']
);
} else {
return $result;
}
if ($clusters === array()) {
return $result;
}
/*
* Convert structural clusters to normalized base letters.
*/
$letters = array();
foreach ($clusters as $cluster) {
$letter = '';
if (
is_array($cluster)
&& isset($cluster['letter'])
&& is_string($cluster['letter'])
) {
$letter = $cluster['letter'];
} elseif (is_string($cluster)) {
$letter = $cluster;
}
if (
$letter !== ''
&& function_exists('ace_normalize_radical')
) {
$letter = ace_normalize_radical($letter);
}
$letters[] = $letter;
}
/*
* Locate r0.
*
* Accept both the current and older field names.
*/
$r0Position = null;
if (
array_key_exists('r0_position', $result)
&& is_int($result['r0_position'])
) {
$r0Position = $result['r0_position'];
} elseif (
array_key_exists('r0_index', $result)
&& is_int($result['r0_index'])
) {
$r0Position = $result['r0_index'];
}
if (
$r0Position === null
|| $r0Position < 0
|| !isset($letters[$r0Position])
) {
return $result;
}
/*
* Form X exclusion.
*
* Form X uses:
*
* است
*
* or, after a finite-person prefix:
*
* ست
*
* Its ت must not be classified as vm.
*/
$fs = isset($result['fs'])
? (string) $result['fs']
: '';
if (
$fs === 'است'
|| $fs === 'ست'
|| !empty($result['has_ist_prefix'])
|| !empty($result['is_form_x'])
|| (
isset($result['form'])
&& strtoupper((string) $result['form']) === 'X'
)
) {
return $result;
}
/*
* Also detect the Form X sequence directly when fs has not
* yet been assigned.
*/
$sequenceStart = 0;
if (
isset($letters[0])
&& in_array(
$letters[0],
array('ي', 'ت', 'ء', 'ن'),
true
)
) {
$sequenceStart = 1;
}
$hasFormXSequence = (
isset(
$letters[$sequenceStart],
$letters[$sequenceStart + 1]
)
&& $letters[$sequenceStart] === 'س'
&& $letters[$sequenceStart + 1] === 'ت'
&& $r0Position === $sequenceStart + 2
);
if ($hasFormXSequence) {
return $result;
}
/*
* Form VII.
*
* The derivational ن must occur immediately before r0.
*/
$beforeR0 = $r0Position - 1;
if (
$beforeR0 >= 0
&& isset($letters[$beforeR0])
&& $letters[$beforeR0] === 'ن'
) {
/*
* Use structural evidence when available.
*
* The positional relationship is sufficient when the
* form-detection fields have not yet been produced.
*/
$hasForm7Evidence = (
!empty($result['has_form7_n'])
|| !empty($result['is_form_vii'])
|| (
isset($result['form'])
&& strtoupper((string) $result['form']) === 'VII'
)
|| $r0Position >= 1
);
if ($hasForm7Evidence) {
$result['vm'] = 'ن';
$result['vm_position'] = $beforeR0;
$result['vm_surface'] = 'ن';
$result['vm_underlying'] = 'ن';
return $result;
}
}
/*
* Form VIII.
*
* The derivational marker normally occurs immediately
* after r0.
*/
$afterR0 = $r0Position + 1;
if (
!isset($letters[$afterR0])
|| !in_array(
$letters[$afterR0],
array('ت', 'ط', 'د'),
true
)
) {
return $result;
}
$surfaceMarker = $letters[$afterR0];
/*
* Prefer explicit Form VIII evidence.
*
* When form detection has not yet run, accept the structural
* position only when at least two later radical positions
* remain. This prevents a final radical ت, ط, or د from being
* incorrectly classified as vm.
*/
$hasTwoFollowingSlots = (
isset(
$letters[$afterR0 + 1],
$letters[$afterR0 + 2]
)
);
$hasForm8Evidence = (
!empty($result['has_form8_t'])
|| !empty($result['form8_marker'])
|| !empty($result['form8_assimilation'])
|| !empty($result['is_form_viii'])
|| (
isset($result['form'])
&& strtoupper((string) $result['form']) === 'VIII'
)
|| $hasTwoFollowingSlots
);
if (!$hasForm8Evidence) {
return $result;
}
/*
* Store both the surface realization and the underlying
* Form VIII ت.
*/
$result['vm'] = $surfaceMarker;
$result['vm_position'] = $afterR0;
$result['vm_surface'] = $surfaceMarker;
$result['vm_underlying'] = 'ت';
return $result;
}
/**
* ============================================================
* FUNCTION 8
* Extract vn
* ============================================================
*
* vn is an initial derivational marker.
*
* It is not:
* - an imperfect person marker;
* - an internal Form VIII marker;
* - part of the lexical root.
*
* Recognized values:
*
* Form V:
* تَفَعَّلَ
* vn = ت
*
* Form VI:
* تَفَاعَلَ
* vn = ت
*
* Form IV perfect or imperative:
* أَفْعَلَ
* أَفْعِلْ
* vn = أ
*
* Imperfect person markers remain excluded:
*
* يَفْعَلُ
* تَفْعَلُ
* أَفْعَلُ
* نَفْعَلُ
*
* Forms VII, VIII, and X are identified through fs, vm,
* or their structural feature flags.
*
* Supported calling styles:
*
* ace_extract_vn($analysis)
* ace_extract_vn($markedStem, $analysis)
*
* Adds:
* - vn
* - vn_position
*
* @param mixed $input
* @param array<string, mixed> $analysis
*
* @return array<string, mixed>
*/
function ace_extract_vn(
$input,
array $analysis = array()
): array {
/*
* Normalize supported calling styles.
*/
if (is_array($input)) {
$result = $input;
} else {
$result = $analysis;
if (is_string($input) && $input !== '') {
$result['marked_stem'] = $input;
}
}
/*
* Reset only fields owned by this stage.
*/
$result['vn'] = '';
$result['vn_position'] = null;
/*
* Obtain structural clusters.
*/
if (
isset($result['structural_clusters'])
&& is_array($result['structural_clusters'])
) {
$clusters = array_values(
$result['structural_clusters']
);
} elseif (
isset($result['clusters'])
&& is_array($result['clusters'])
) {
$clusters = array_values(
$result['clusters']
);
} else {
return $result;
}
if ($clusters === array()) {
return $result;
}
/*
* Locate r0.
*/
$r0Position = null;
if (
isset($result['r0_position'])
&& is_int($result['r0_position'])
) {
$r0Position = $result['r0_position'];
} elseif (
isset($result['r0_index'])
&& is_int($result['r0_index'])
) {
$r0Position = $result['r0_index'];
}
/*
* vn must occur before r0.
*/
if (
$r0Position === null
|| $r0Position <= 0
|| !isset($clusters[$r0Position - 1])
|| !is_array($clusters[$r0Position - 1])
) {
return $result;
}
$candidatePosition = $r0Position - 1;
$candidateCluster = $clusters[$candidatePosition];
$candidate = isset($candidateCluster['letter'])
&& is_string($candidateCluster['letter'])
? $candidateCluster['letter']
: '';
if ($candidate === '') {
return $result;
}
$candidateMarks = isset($candidateCluster['marks'])
&& is_string($candidateCluster['marks'])
? $candidateCluster['marks']
: '';
/*
* Obtain previously extracted structural evidence.
*/
$fs = isset($result['fs'])
&& is_string($result['fs'])
? $result['fs']
: '';
$v1 = isset($result['v1'])
&& is_string($result['v1'])
? $result['v1']
: '';
$r1Repeat = !empty($result['r1_repeat']);
/*
* Forms VII, VIII, and X do not use vn.
*
* Do not infer these forms merely from adjacent letters.
* A sequence such as ي + س + ت can belong to a Form I
* root such as س-ت-ر.
*/
$isFormXStructure = (
$fs === 'است'
|| $fs === 'ست'
|| !empty($result['has_ist_prefix'])
);
$isFormVIIIStructure = (
!empty($result['has_form8_t'])
|| (
isset($result['vm'])
&& $result['vm'] === 'ت'
)
);
$isFormVIIStructure = (
$fs === 'ن'
|| $fs === 'ان'
|| !empty($result['has_form7_n'])
);
if (
$isFormXStructure
|| $isFormVIIIStructure
|| $isFormVIIStructure
) {
return $result;
}
/*
* Form V:
*
* ت + r0 + repeated r1
*/
if (
$candidate === 'ت'
&& $r1Repeat
) {
$result['vn'] = 'ت';
$result['vn_position'] = $candidatePosition;
return $result;
}
/*
* Form VI:
*
* ت + r0 + ا + r1 + r2
*
* v1 represents the long vowel between r0 and r1.
*/
$hasFormVILongAlif = (
$v1 === 'ا'
|| !empty($result['long_a_after_r0'])
);
if (
$candidate === 'ت'
&& $hasFormVILongAlif
) {
$result['vn'] = 'ت';
$result['vn_position'] = $candidatePosition;
return $result;
}
/*
* Form IV perfect or imperative:
*
* أ + r0 + r1 + r2
*
* A normalized cluster may contain ء instead of أ.
* Store the structural marker consistently as أ.
*/
$isHamzaCandidate = in_array(
$candidate,
array('أ', 'ء'),
true
);
if (!$isHamzaCandidate) {
return $result;
}
/*
* Form IV vn must be the initial structural cluster.
*
* This prevents a lexical or internal hamza from being
* classified as a derivational marker.
*/
if ($candidatePosition !== 0) {
return $result;
}
/*
* Positive Form IV evidence may come from:
*
* - Function 6 detecting hamzat al-qat;
* - an explicitly identified Form IV candidate;
* - the surface fatḥa on initial أ.
*
* Repetition and long ā evidence would indicate another
* derived pattern and therefore block Form IV assignment.
*/
$hasFatha = (
mb_strpos($candidateMarks, 'َ') !== false
);
$hasForm4Candidate = false;
if (
isset($result['form_candidate'])
&& in_array(
$result['form_candidate'],
array(4, '4', 'IV'),
true
)
) {
$hasForm4Candidate = true;
}
if (
isset($result['form_candidates'])
&& is_array($result['form_candidates'])
) {
foreach ($result['form_candidates'] as $formCandidate) {
if (
in_array(
$formCandidate,
array(4, '4', 'IV'),
true
)
) {
$hasForm4Candidate = true;
break;
}
}
}
$hasForm4Evidence = (
(
!empty($result['has_hamzat_qat'])
|| $hasForm4Candidate
|| $hasFatha
)
&& !$r1Repeat
&& !$hasFormVILongAlif
);
if ($hasForm4Evidence) {
$result['vn'] = 'أ';
$result['vn_position'] = $candidatePosition;
}
return $result;
}
/**
* ============================================================
* FUNCTION 9
* Detect Arabic Verb Form
* ============================================================
*
* Detects Forms I-X from previously extracted structural
* features while preserving the complete analysis row.
*
* @param array<string, mixed> $analysis
*
* @return array<string, mixed>
*/
function ace_detect_form(array $analysis): array
{
$result = $analysis;
$result['form'] = '';
$result['form_reason'] = '';
$vn = isset($result['vn'])
? (string) $result['vn']
: '';
$vm = isset($result['vm'])
? (string) $result['vm']
: '';
$fs = isset($result['fs'])
? (string) $result['fs']
: '';
$v1 = isset($result['v1'])
? (string) $result['v1']
: '';
$r1Repeat = !empty($result['r1_repeat']);
$r2Repeat = !empty($result['r2_repeat']);
$hasForm8T = !empty($result['has_form8_t']);
$hasForm7N = !empty($result['has_form7_n']);
$hasIstPrefix = !empty($result['has_ist_prefix']);
/*
* Obtain structural letters for sequence-based detection.
*/
$clusters = array();
if (
isset($result['structural_clusters'])
&& is_array($result['structural_clusters'])
) {
$clusters = array_values(
$result['structural_clusters']
);
} elseif (
isset($result['clusters'])
&& is_array($result['clusters'])
) {
$clusters = array_values(
$result['clusters']
);
}
$letters = array();
foreach ($clusters as $cluster) {
$letters[] = (
is_array($cluster)
&& isset($cluster['letter'])
&& is_string($cluster['letter'])
)
? $cluster['letter']
: '';
}
/*
* Detect an optional imperfect person marker.
*/
$sequenceStart = 0;
if (
isset($letters[0])
&& in_array(
$letters[0],
array('ي', 'ت', 'أ', 'ن'),
true
)
) {
$sequenceStart = 1;
}
/*
* Form X:
*
* Perfect:
* ا + س + ت + r0 + r1 + r2
*
* Imperfect:
* person marker + س + ت + r0 + r1 + r2
*/
$hasVisibleFormXSequence = (
isset(
$letters[$sequenceStart],
$letters[$sequenceStart + 1]
)
&& $letters[$sequenceStart] === 'س'
&& $letters[$sequenceStart + 1] === 'ت'
);
if (
$hasIstPrefix
|| $fs === 'است'
|| $fs === 'ست'
|| $hasVisibleFormXSequence
) {
$result['form'] = 'X';
$result['form_reason'] =
'Form X است or ست sequence detected';
return $result;
}
/*
* Form IX:
*
* Check before Forms V and IV because imperfect person
* markers such as ت and أ must not override r2 repetition.
*/
if ($r2Repeat) {
$result['form'] = 'IX';
$result['form_reason'] =
'Third radical is repeated';
return $result;
}
/*
* Form VII:
*
* The current pipeline may store fs as either ن or ان.
*/
if (
$hasForm7N
|| $fs === 'ن'
|| $fs === 'ان'
|| $vm === 'ن'
) {
$result['form'] = 'VII';
$result['form_reason'] =
'Form VII ن marker detected before r0';
return $result;
}
/*
* Form VIII:
*
* Do not classify solely from an arbitrary vm = ت.
* Require Form VIII evidence or an assimilated marker.
*/
if (
$hasForm8T
|| !empty($result['form8_marker'])
|| !empty($result['form8_assimilation'])
|| in_array($vm, array('ط', 'د'), true)
|| (
$vm === 'ت'
&& $vn === ''
&& $fs === ''
)
) {
$result['form'] = 'VIII';
$result['form_reason'] =
'Internal Form VIII marker detected after r0';
return $result;
}
/*
* Form VI: تَفَاعَلَ
*/
if ($vn === 'ت' && $v1 === 'ا') {
$result['form'] = 'VI';
$result['form_reason'] =
'Derivational ت with long ا after r0';
return $result;
}
/*
* Form V: تَفَعَّلَ
*/
if ($vn === 'ت' && $r1Repeat) {
$result['form'] = 'V';
$result['form_reason'] =
'Derivational ت with repeated second radical';
return $result;
}
/*
* Form IV: أَفْعَلَ
*/
if ($vn === 'أ') {
$result['form'] = 'IV';
$result['form_reason'] =
'Initial derivational hamza أ detected';
return $result;
}
/*
* Form III: فَاعَلَ
*/
if ($v1 === 'ا') {
$result['form'] = 'III';
$result['form_reason'] =
'Long ا detected after the first radical';
return $result;
}
/*
* Form II: فَعَّلَ
*/
if ($r1Repeat) {
$result['form'] = 'II';
$result['form_reason'] =
'Second radical is repeated';
return $result;
}
/*
* Default: Form I.
*/
$result['form'] = 'I';
$result['form_reason'] =
'No derived-form marker detected';
return $result;
}
/**
* ============================================================
* FUNCTION 12
* Extract Root Candidates
* ============================================================
*
* Builds one root candidate from the currently assigned radical
* positions while preserving all earlier analysis fields.
*
* Expected fields:
*
* r0
* r1
* r2
*
* Adds:
*
* root
* root_letters
* root_candidates
* candidate_count
*/
function ace_extract_root_candidates(array $analysis): array
{
/*
* Preserve every field produced by earlier pipeline stages.
*/
$result = $analysis;
/*
* Reset only fields owned by this stage.
*/
$result['root'] = '';
$result['root_letters'] = array();
$result['root_candidates'] = array();
$result['candidate_count'] = 0;
$rootLetters = array();
/*
* Collect valid radical values in structural order.
*/
foreach (array('r0', 'r1', 'r2') as $position) {
$radical = isset($analysis[$position])
&& is_string($analysis[$position])
? trim($analysis[$position])
: '';
if ($radical === '') {
continue;
}
/*
* Normalize hamza variants if the helper is available.
*/
if (function_exists('ace_normalize_radical')) {
$radical = ace_normalize_radical($radical);
}
$rootLetters[] = $radical;
}
/*
* A normal root candidate requires three radicals.
*
* Do not manufacture an incomplete root from one or two
* extracted positions.
*/
if (count($rootLetters) !== 3) {
$result['root_letters'] = $rootLetters;
return $result;
}
$root = implode('', $rootLetters);
$result['root'] = $root;
$result['root_letters'] = $rootLetters;
$result['root_candidates'] = array($root);
$result['candidate_count'] = 1;
return $result;
}
/**
* ============================================================
* FUNCTION 13
* Generate Root Candidates
* ============================================================
*
* Validates and generates root candidates from the radical data
* produced by Function 12 while preserving all earlier fields.
*
* Expected fields:
*
* root
* root_letters
* root_candidates
* candidate_count
*
* Updates:
*
* root_candidates
* candidate_count
*/
function ace_generate_root_candidates(array $analysis): array
{
/*
* Preserve every field produced by earlier pipeline stages.
*/
$result = $analysis;
/*
* Reset only fields owned by this stage.
*/
$result['root_candidates'] = array();
$result['candidate_count'] = 0;
$rootLetters = array();
/*
* Prefer the structurally assigned root-letter array.
*/
if (
isset($analysis['root_letters'])
&& is_array($analysis['root_letters'])
) {
foreach ($analysis['root_letters'] as $letter) {
if (!is_string($letter)) {
continue;
}
$letter = trim($letter);
if ($letter === '') {
continue;
}
if (function_exists('ace_normalize_radical')) {
$letter = ace_normalize_radical($letter);
}
$rootLetters[] = $letter;
}
}
/*
* A normal root candidate requires exactly three radicals.
*
* Do not generate candidates from incomplete roots.
*/
if (count($rootLetters) !== 3) {
return $result;
}
$candidate = implode('', $rootLetters);
if ($candidate === '') {
return $result;
}
$result['root_candidates'] = array($candidate);
$result['candidate_count'] = 1;
/*
* Keep the assembled root synchronized.
*/
$result['root'] = $candidate;
$result['root_letters'] = $rootLetters;
return $result;
}
/**
* ============================================================
* FUNCTION 14
* Determine Verb Form
* ============================================================
*
* Preserves all earlier fields and adds:
*
* form
*/
function ace_determine_form(array $analysis): array
{
$result = $analysis;
$result['form'] = 'Unknown';
$toString = static function ($value): string {
return is_scalar($value)
? (string) $value
: '';
};
$r0 = $toString($analysis['r0'] ?? '');
$r1 = $toString($analysis['r1'] ?? '');
$r2 = $toString($analysis['r2'] ?? '');
$vn = $toString($analysis['vn'] ?? '');
$vm = $toString($analysis['vm'] ?? '');
$fs = $toString($analysis['fs'] ?? '');
$v1 = $toString($analysis['v1'] ?? '');
$r1Repeat = !empty($analysis['r1_repeat']);
$r2Repeat = !empty($analysis['r2_repeat']);
/*
* Support either an explicit boolean field or the structural
* vowel-slot representation.
*/
$longV1 = !empty($analysis['long_v1'])
|| $v1 === 'ا';
/*
* A form cannot be selected until all three radicals exist.
*/
if ($r0 === '' || $r1 === '' || $r2 === '') {
return $result;
}
/*
* Strongest and most specific markers first.
*/
/*
* Form X: استفعل
*/
if ($fs === 'است') {
$result['form'] = 'X';
return $result;
}
/*
* Form IX: افعلّ
*
* Test before less-specific internal markers.
*/
if ($r2Repeat) {
$result['form'] = 'IX';
return $result;
}
/*
* Form VIII: افتعل
*/
if ($vm === 'ت') {
$result['form'] = 'VIII';
return $result;
}
/*
* Form VII: انفعل
*
* Prefer the standardized value ن, but temporarily accept ان.
*/
if ($fs === 'ن' || $fs === 'ان') {
$result['form'] = 'VII';
return $result;
}
/*
* Forms V and VI contain a derivational ت.
*/
$hasDerivationalT = $vn === 'ت'
|| $fs === 'ت'
|| $vm === 'ت';
if ($hasDerivationalT && $r1Repeat) {
$result['form'] = 'V';
return $result;
}
if ($hasDerivationalT && $longV1) {
$result['form'] = 'VI';
return $result;
}
/*
* Form IV: أفعل
*
* fs should contain أ only when it has already been identified
* as derivational rather than as first-person imperfect vn.
*/
if ($fs === 'أ') {
$result['form'] = 'IV';
return $result;
}
/*
* Form III: فاعل
*/
if ($longV1) {
$result['form'] = 'III';
return $result;
}
/*
* Form II: فعّل
*/
if ($r1Repeat) {
$result['form'] = 'II';
return $result;
}
$result['form'] = 'I';
return $result;
}
/**
* ============================================================
* FUNCTION 15
* Determine Possible Verb Form(s)
* ============================================================
*
* Preserves all earlier fields and adds:
*
* form_candidates
* form_candidate_count
*/
function ace_determine_form_candidates(array $analysis): array
{
$result = $analysis;
$result['form_candidates'] = array();
$result['form_candidate_count'] = 0;
$toString = static function ($value): string {
return is_scalar($value)
? (string) $value
: '';
};
$containsShadda = static function ($value): bool {
if (is_array($value)) {
return in_array("\u{0651}", $value, true);
}
if (is_string($value)) {
return mb_strpos(
$value,
"\u{0651}",
0,
'UTF-8'
) !== false;
}
return false;
};
$r0 = $toString($analysis['r0'] ?? '');
$r1 = $toString($analysis['r1'] ?? '');
$r2 = $toString($analysis['r2'] ?? '');
$vn = $toString($analysis['vn'] ?? '');
$vm = $toString($analysis['vm'] ?? '');
$fs = $toString($analysis['fs'] ?? '');
$v1 = $toString($analysis['v1'] ?? '');
$plainStem = $toString($analysis['plain_stem'] ?? '');
/*
* Support both explicit repeat flags and shadda marks.
*/
$r1Repeat = !empty($analysis['r1_repeat'])
|| $containsShadda($analysis['r1_marks'] ?? '');
$r2Repeat = !empty($analysis['r2_repeat'])
|| $containsShadda($analysis['r2_marks'] ?? '');
/*
* A long alif after r0 is the primary marker used for
* Forms III and VI.
*/
$longV1 = !empty($analysis['long_v1'])
|| $v1 === 'ا';
/*
* Do not propose a form without a complete radical framework.
*/
if ($r0 === '' || $r1 === '' || $r2 === '') {
return $result;
}
/*
* Detect the derivational ت used by Forms V and VI.
*/
$hasInitialDerivationalT = $vn === 'ت'
|| $fs === 'ت';
/*
* Imperfect forms can contain a conjugational prefix followed
* by the derivational ت:
*
* يت...
* تت...
* أت...
* نت...
*/
$firstTwoLetters = mb_substr(
$plainStem,
0,
2,
'UTF-8'
);
if (
in_array(
$firstTwoLetters,
array('يت', 'تت', 'أت', 'ات', 'نت'),
true
)
) {
$hasInitialDerivationalT = true;
}
/*
* Specific structural markers take precedence.
*/
if ($fs === 'است') {
$result['form_candidates'][] = 'X';
} elseif ($r2Repeat) {
$result['form_candidates'][] = 'IX';
} elseif ($vm === 'ت') {
$result['form_candidates'][] = 'VIII';
} elseif ($fs === 'ن' || $fs === 'ان') {
$result['form_candidates'][] = 'VII';
} elseif ($r1Repeat && $hasInitialDerivationalT) {
$result['form_candidates'][] = 'V';
} elseif ($longV1 && $hasInitialDerivationalT) {
$result['form_candidates'][] = 'VI';
} elseif ($fs === 'أ') {
$result['form_candidates'][] = 'IV';
} elseif ($r1Repeat) {
$result['form_candidates'][] = 'II';
} elseif ($longV1) {
$result['form_candidates'][] = 'III';
} else {
$result['form_candidates'][] = 'I';
}
$result['form_candidates'] = array_values(
array_unique($result['form_candidates'])
);
$result['form_candidate_count'] = count(
$result['form_candidates']
);
return $result;
}
/**
* ============================================================
* FUNCTION 16
* Build Root Candidates
* ============================================================
*
* Builds and normalizes the complete root-candidate collection.
*
* Preserves all earlier fields and updates:
*
* root
* root_candidates
* root_candidate_count
* candidate_count
*
* @param array $analysis
*
* @return array
*/
function ace_build_root_candidates(array $analysis)
{
$result = $analysis;
$candidates = array();
$toString = static function ($value) {
return is_scalar($value)
? trim((string) $value)
: '';
};
$normalizeCandidate = static function ($candidate) {
if (!is_string($candidate)) {
return '';
}
$candidate = trim($candidate);
$candidate = str_replace('ـ', '', $candidate);
$normalized = preg_replace(
'/[\x{064B}-\x{065F}\x{0670}\x{06D6}-\x{06ED}]/u',
'',
$candidate
);
return is_string($normalized)
? $normalized
: '';
};
$r0 = $toString(isset($analysis['r0']) ? $analysis['r0'] : '');
$r1 = $toString(isset($analysis['r1']) ? $analysis['r1'] : '');
$r2 = $toString(isset($analysis['r2']) ? $analysis['r2'] : '');
/*
* Prefer the direct structurally extracted root.
*/
if ($r0 !== '' && $r1 !== '' && $r2 !== '') {
$candidates[] = $r0 . $r1 . $r2;
}
/*
* Include candidates generated by earlier stages.
*/
if (
isset($analysis['root_candidates'])
&& is_array($analysis['root_candidates'])
) {
foreach ($analysis['root_candidates'] as $candidate) {
$candidate = $normalizeCandidate($candidate);
if ($candidate !== '') {
$candidates[] = $candidate;
}
}
}
/*
* Include roots previously marked as valid, when present.
*
* This supports repeated or independently tested pipeline
* execution, but validation should normally run after this
* function.
*/
if (
isset($analysis['validated_roots'])
&& is_array($analysis['validated_roots'])
) {
foreach ($analysis['validated_roots'] as $validatedRoot) {
if (!is_array($validatedRoot)) {
continue;
}
if (empty($validatedRoot['valid'])) {
continue;
}
$root = isset($validatedRoot['root'])
? $normalizeCandidate($validatedRoot['root'])
: '';
if ($root !== '') {
$candidates[] = $root;
}
}
}
/*
* Normalize every candidate before deduplication.
*/
$normalizedCandidates = array();
foreach ($candidates as $candidate) {
$candidate = $normalizeCandidate($candidate);
if ($candidate !== '') {
$normalizedCandidates[] = $candidate;
}
}
$candidates = array_values(
array_unique($normalizedCandidates)
);
/*
* Preserve any previously selected root when it remains a
* valid member of the candidate collection.
*/
$existingRoot = isset($analysis['root'])
? $normalizeCandidate($analysis['root'])
: '';
if (
$existingRoot !== ''
&& in_array($existingRoot, $candidates, true)
) {
$result['root'] = $existingRoot;
} else {
$result['root'] = isset($candidates[0])
? $candidates[0]
: '';
}
$result['root_candidates'] = $candidates;
$result['root_candidate_count'] = count($candidates);
/*
* Keep compatibility with Function 13 and existing pages.
*/
$result['candidate_count'] = count($candidates);
return $result;
}
/**
* ============================================================
* FUNCTION 17
* Validate Root Candidates
* ============================================================
*
* Validates the complete root-candidate collection built by
* Function 17.
*
* Preserves all earlier fields and adds:
*
* validated_roots
* valid_roots
* valid_root_count
* has_valid_root
*
* @param array $analysis
*
* @return array
*/
function ace_validate_root_candidates(array $analysis)
{
$result = $analysis;
$candidates = array();
if (
isset($analysis['root_candidates'])
&& is_array($analysis['root_candidates'])
) {
$candidates = $analysis['root_candidates'];
}
$validated = array();
$validRoots = array();
$seenRoots = array();
foreach ($candidates as $candidate) {
/*
* A root candidate must be a string.
*/
if (!is_string($candidate)) {
$validated[] = array(
'root' => '',
'letters' => array(),
'letter_count' => 0,
'valid' => false,
'root_type' => 'invalid',
'reason' => 'Root candidate is not a string',
);
continue;
}
/*
* Remove whitespace and tatweel.
*/
$candidate = trim($candidate);
$candidate = str_replace('ـ', '', $candidate);
/*
* Remove Arabic combining marks.
*/
$normalizedCandidate = preg_replace(
'/[\x{064B}-\x{065F}\x{0670}\x{06D6}-\x{06ED}]/u',
'',
$candidate
);
if (!is_string($normalizedCandidate)) {
$normalizedCandidate = '';
}
$candidate = $normalizedCandidate;
/*
* Reject empty candidates.
*/
if ($candidate === '') {
$validated[] = array(
'root' => '',
'letters' => array(),
'letter_count' => 0,
'valid' => false,
'root_type' => 'invalid',
'reason' => 'Empty root candidate',
);
continue;
}
/*
* Avoid validating the same normalized root twice.
*/
if (isset($seenRoots[$candidate])) {
continue;
}
$seenRoots[$candidate] = true;
$letters = preg_split(
'//u',
$candidate,
-1,
PREG_SPLIT_NO_EMPTY
);
if (!is_array($letters)) {
$letters = array();
}
$letterCount = count($letters);
$valid = false;
$rootType = 'invalid';
$reason = 'Root candidate must contain three or four letters';
if ($letterCount === 3) {
$valid = true;
$rootType = 'trilateral';
$reason = 'Valid triliteral root candidate';
} elseif ($letterCount === 4) {
$valid = true;
$rootType = 'quadriliteral';
$reason = 'Valid quadriliteral root candidate';
}
$validated[] = array(
'root' => $candidate,
'letters' => $letters,
'letter_count' => $letterCount,
'valid' => $valid,
'root_type' => $rootType,
'reason' => $reason,
);
if ($valid) {
$validRoots[] = $candidate;
}
}
$result['validated_roots'] = $validated;
$result['valid_roots'] = array_values(array_unique($validRoots));
$result['valid_root_count'] = count($result['valid_roots']);
$result['has_valid_root'] = $result['valid_root_count'] > 0;
return $result;
}
/**
* ============================================================
* FUNCTION 18
* Select Final Root
* ============================================================
*
* Chooses the best root candidate from the generated list.
*
* Current strategy:
* 1. No candidates → empty root.
* 2. One candidate → select it.
* 3. Multiple candidates → select the first.
*
* Later versions may replace this logic with:
* - dictionary lookup
* - verb-form compatibility
* - weak-root reconstruction
* - frequency scoring
*/
function ace_select_final_root(array $analysis): array
{
$candidates = $analysis['root_candidates'] ?? [];
$analysis['final_root'] = '';
if (empty($candidates)) {
return $analysis;
}
/*
* Single candidate.
*/
if (count($candidates) === 1) {
$analysis['final_root'] = $candidates[0];
return $analysis;
}
/*
* Placeholder for future ranking logic.
*
* Future:
* - dictionary validation
* - weak-root recovery
* - form compatibility
* - confidence scoring
*/
$analysis['final_root'] = reset($candidates);
return $analysis;
}
/**
* ============================================================
* FUNCTION 19
* Identify Form Candidates
* ============================================================
*
* Uses the extracted structural markers to identify all
* compatible Arabic derived verb forms.
*
* Important:
* - This function does not remove prefixes.
* - It only interprets markers already extracted.
* - More than one candidate may be returned when the
* available structure is ambiguous.
*
* @param array<string, mixed> $analysis
*
* @return array<int, string>
*/
function ace_identify_form_candidates(array $analysis): array
{
$forms = [];
$vn = (string) ($analysis['vn'] ?? '');
$vm = (string) ($analysis['vm'] ?? '');
$fs = (string) ($analysis['fs'] ?? '');
$v1 = (string) ($analysis['v1'] ?? '');
$r1Repeat = !empty($analysis['r1_repeat']);
$r2Repeat = !empty($analysis['r2_repeat']);
/*
* Form X
*
* Perfect:
* اِسْتَفْعَلَ
*
* Imperfect:
* يَسْتَفْعِلُ
*
* Depending on Function 10 extraction, fs may contain
* either است or ست.
*/
if (in_array($fs, ['است', 'ست'], true)) {
$forms[] = 'X';
}
/*
* Form IX
*
* اِفْعَلَّ
*
* The third radical is doubled.
*/
if ($r2Repeat) {
$forms[] = 'IX';
}
/*
* Form VIII
*
* اِفْتَعَلَ
*
* The internal derivational marker is ت.
*/
if ($vm === 'ت') {
$forms[] = 'VIII';
}
/*
* Form VII
*
* اِنْفَعَلَ
* يَنْفَعِلُ
*
* Depending on extraction, fs may contain ان or ن.
*/
if (in_array($fs, ['ان', 'ن'], true)) {
$forms[] = 'VII';
}
/*
* Form VI
*
* تَفَاعَلَ
*
* Initial derivational ت plus a long ا after r0.
*/
if ($vn === 'ت' && $v1 === 'ا') {
$forms[] = 'VI';
}
/*
* Form V
*
* تَفَعَّلَ
*
* Initial derivational ت plus doubled r1.
*/
if ($vn === 'ت' && $r1Repeat) {
$forms[] = 'V';
}
/*
* Form IV
*
* أَفْعَلَ
*
* Initial derivational hamzah.
*/
if (in_array($vn, ['أ', 'إ'], true)) {
$forms[] = 'IV';
}
/*
* Form III
*
* فَاعَلَ
*
* A long ا follows r0, provided the initial ت has not
* already identified the structure as Form VI.
*/
if ($v1 === 'ا' && $vn !== 'ت') {
$forms[] = 'III';
}
/*
* Form II
*
* فَعَّلَ
*
* The second radical is doubled, provided an initial ت
* has not already identified the structure as Form V.
*/
if ($r1Repeat && $vn !== 'ت') {
$forms[] = 'II';
}
/*
* Form I
*
* Used only when no derived-form marker was detected.
*/
if (empty($forms)) {
$forms[] = 'I';
}
return array_values(array_unique($forms));
}
/**
* ============================================================
* FUNCTION 20
* Select Best Root Candidate
* ============================================================
*
* Current selection rules:
* 1. Prefer the first valid triliteral candidate.
* 2. Ignore empty or non-string candidates.
* 3. Fall back to the first usable candidate.
*
* Later, candidates can be scored using:
* - detected verb form
* - weak-root compatibility
* - dictionary validation
* - structural confidence
*/
function ace_select_best_root_candidate(array $analysis): array
{
$candidates = $analysis['root_candidates'] ?? [];
/*
* Always initialize output fields.
*/
$analysis['selected_root'] = '';
$analysis['selection_reason'] = 'No usable candidates';
if (!is_array($candidates) || empty($candidates)) {
$analysis['selection_reason'] = 'No candidates';
return $analysis;
}
$usableCandidates = [];
foreach ($candidates as $candidate) {
if (!is_string($candidate)) {
continue;
}
$candidate = trim($candidate);
if ($candidate === '') {
continue;
}
/*
* Root candidates should normally already be plain,
* but remove Arabic marks before measuring length.
*/
$plainCandidate = preg_replace(
'/[\x{064B}-\x{065F}\x{0670}\x{06D6}-\x{06ED}\x{0640}]/u',
'',
$candidate
);
if (!is_string($plainCandidate) || $plainCandidate === '') {
continue;
}
$usableCandidates[] = $plainCandidate;
/*
* Prefer the first triliteral candidate.
*/
if (mb_strlen($plainCandidate, 'UTF-8') === 3) {
$analysis['selected_root'] = $plainCandidate;
$analysis['selection_reason'] =
'First valid triliteral candidate';
return $analysis;
}
}
/*
* Fall back to the first usable non-triliteral candidate.
*/
if (!empty($usableCandidates)) {
$analysis['selected_root'] = $usableCandidates[0];
$analysis['selection_reason'] =
'No triliteral candidate; selected first usable candidate';
}
return $analysis;
}
/**
* ============================================================
* FUNCTION 21
* Detect Weak Radicals
* ============================================================
*
* Detects weak radicals in the selected triliteral root.
*
* Root-source priority:
* 1. selected_root
* 2. final_root
* 3. root
* 4. extracted r0/r1/r2 slots
*
* Weak radical letters:
* - و = waw
* - ي = ya
* - ا = unresolved surface alif
* - ى = unresolved surface alif maqsura
*
* Hamza is not treated as a weak radical.
*
* Adds:
* - weak_analysis_root
* - weak_analysis_source
* - weak_r0
* - weak_r1
* - weak_r2
* - r0_is_weak
* - r1_is_weak
* - r2_is_weak
* - has_weak_radical
* - weak_radical_count
* - weak_radical_positions
* - weak_pattern
* - weak_class
* - confirmed_underlying_weak_count
* - has_unresolved_surface_weak_letter
*
* @param array $analysis
*
* @return array
*/
function ace_detect_weak_radicals(array $analysis): array
{
/*
* Preserve every field produced by preceding stages.
*/
$result = $analysis;
/*
* Find the best available root.
*/
$root = '';
$rootSource = 'radical_slots';
foreach (
array(
'selected_root',
'final_root',
'root',
) as $rootField
) {
if (
isset($analysis[$rootField])
&& is_string($analysis[$rootField])
&& trim($analysis[$rootField]) !== ''
) {
$root = trim($analysis[$rootField]);
$rootSource = $rootField;
break;
}
}
/*
* Begin with the extracted radical slots.
*/
$r0 = (
isset($analysis['r0'])
&& is_scalar($analysis['r0'])
)
? trim((string) $analysis['r0'])
: '';
$r1 = (
isset($analysis['r1'])
&& is_scalar($analysis['r1'])
)
? trim((string) $analysis['r1'])
: '';
$r2 = (
isset($analysis['r2'])
&& is_scalar($analysis['r2'])
)
? trim((string) $analysis['r2'])
: '';
/*
* Use a selected root only when it is triliteral.
*
* Quadriliteral roots must not be silently truncated.
*/
if ($root !== '') {
$rootLetters = preg_split(
'//u',
$root,
-1,
PREG_SPLIT_NO_EMPTY
);
if (
is_array($rootLetters)
&& count($rootLetters) === 3
) {
$r0 = $rootLetters[0];
$r1 = $rootLetters[1];
$r2 = $rootLetters[2];
} else {
/*
* Fall back to the extracted slots when the selected
* root is not triliteral.
*/
$rootSource = 'radical_slots';
}
}
/*
* Build the effective root used by this function.
*/
$effectiveRoot = $r0 . $r1 . $r2;
/*
* Confirmed underlying weak radicals.
*/
$underlyingWeakLetters = array(
'و',
'ي',
);
/*
* Surface weak letters.
*
* Alif and alif maqsura are treated as unresolved surface
* weak letters until a later reconstruction stage determines
* whether the underlying radical is waw or ya.
*/
$surfaceWeakLetters = array(
'و',
'ي',
'ا',
'ى',
);
$r0IsWeak = (
$r0 !== ''
&& in_array($r0, $surfaceWeakLetters, true)
);
$r1IsWeak = (
$r1 !== ''
&& in_array($r1, $surfaceWeakLetters, true)
);
$r2IsWeak = (
$r2 !== ''
&& in_array($r2, $surfaceWeakLetters, true)
);
/*
* Collect weak-radical positions.
*/
$weakPositions = array();
if ($r0IsWeak) {
$weakPositions[] = 'r0';
}
if ($r1IsWeak) {
$weakPositions[] = 'r1';
}
if ($r2IsWeak) {
$weakPositions[] = 'r2';
}
$weakCount = count($weakPositions);
/*
* Broad positional pattern.
*/
if ($weakCount === 0) {
$weakPattern = 'sound';
} elseif ($weakCount === 1) {
if ($r0IsWeak) {
$weakPattern = 'initial_weak';
} elseif ($r1IsWeak) {
$weakPattern = 'middle_weak';
} else {
$weakPattern = 'final_weak';
}
} elseif ($weakCount === 2) {
if ($r0IsWeak && $r1IsWeak) {
$weakPattern = 'initial_middle_weak';
} elseif ($r1IsWeak && $r2IsWeak) {
$weakPattern = 'middle_final_weak';
} else {
$weakPattern = 'initial_final_weak';
}
} else {
$weakPattern = 'triple_weak';
}
/*
* Traditional Arabic weak-root classification.
*/
if ($weakCount === 0) {
$weakClass = 'sound';
} elseif ($weakCount === 1 && $r0IsWeak) {
$weakClass = 'mithal';
} elseif ($weakCount === 1 && $r1IsWeak) {
$weakClass = 'ajwaf';
} elseif ($weakCount === 1 && $r2IsWeak) {
$weakClass = 'naqis';
} elseif (
$weakCount === 2
&& $r0IsWeak
&& $r2IsWeak
) {
$weakClass = 'lafif_mafruq';
} elseif (
$weakCount === 2
&& (
($r0IsWeak && $r1IsWeak)
|| ($r1IsWeak && $r2IsWeak)
)
) {
$weakClass = 'lafif_maqrun';
} elseif ($weakCount === 3) {
$weakClass = 'triple_weak';
} else {
$weakClass = 'mixed_weak';
}
/*
* Count confirmed waw/ya radicals and identify unresolved
* surface alif forms.
*/
$confirmedUnderlyingCount = 0;
$hasSurfaceAlif = false;
foreach (array($r0, $r1, $r2) as $radical) {
if (
in_array(
$radical,
$underlyingWeakLetters,
true
)
) {
$confirmedUnderlyingCount++;
}
if (
$radical === 'ا'
|| $radical === 'ى'
) {
$hasSurfaceAlif = true;
}
}
/*
* Add fields owned by this stage.
*/
$result['weak_analysis_root'] = $effectiveRoot;
$result['weak_analysis_source'] = $rootSource;
$result['weak_r0'] = $r0;
$result['weak_r1'] = $r1;
$result['weak_r2'] = $r2;
$result['r0_is_weak'] = $r0IsWeak;
$result['r1_is_weak'] = $r1IsWeak;
$result['r2_is_weak'] = $r2IsWeak;
$result['has_weak_radical'] = $weakCount > 0;
$result['weak_radical_count'] = $weakCount;
$result['weak_radical_positions'] = $weakPositions;
$result['weak_pattern'] = $weakPattern;
$result['weak_class'] = $weakClass;
$result['confirmed_underlying_weak_count']
= $confirmedUnderlyingCount;
$result['has_unresolved_surface_weak_letter']
= $hasSurfaceAlif;
return $result;
}
//------------------------
/**
* ============================================================
* FUNCTION 22
* Resolve Mithal Roots
* ============================================================
*
* Placeholder for roots whose first radical is weak:
*
* و-ع-د
* ي-ق-ن
*
* Later this function will reconstruct omitted or transformed
* initial weak radicals.
*
* @param array<string, mixed> $analysis
*
* @return array<string, mixed>
*/
if (!function_exists('ace_resolve_mithal_roots')) {
function ace_resolve_mithal_roots(array $analysis): array
{
$result = $analysis;
$result['mithal_resolution_applied'] = false;
$result['mithal_candidates'] = array();
$result['mithal_resolution_status'] = 'not_implemented';
return $result;
}
}
/**
* ============================================================
* FUNCTION 23
* Resolve Hollow Roots
* ============================================================
*
* Placeholder for roots whose second radical is weak:
*
* ق-و-ل
* ب-ي-ع
*
* Later this function will reconstruct the underlying wāw or
* yāʾ from surface alif, vowel patterns, form, voice, and tense.
*
* @param array<string, mixed> $analysis
*
* @return array<string, mixed>
*/
if (!function_exists('ace_resolve_hollow_roots')) {
function ace_resolve_hollow_roots(array $analysis): array
{
$result = $analysis;
$result['hollow_resolution_applied'] = false;
$result['hollow_candidates'] = array();
$result['hollow_resolution_status'] = 'not_implemented';
return $result;
}
}
/**
* ============================================================
* FUNCTION 24
* Resolve Defective Roots
* ============================================================
*
* Placeholder for roots whose final radical is weak:
*
* د-ع-و
* ر-م-ي
*
* Later this function will handle:
*
* - retained final و or ي
* - final ا or ى
* - deleted final weak radicals
* - suffix absorption
* - ambiguous final و/ي reconstruction
*
* @param array<string, mixed> $analysis
*
* @return array<string, mixed>
*/
if (!function_exists('ace_resolve_defective_roots')) {
function ace_resolve_defective_roots(array $analysis): array
{
$result = $analysis;
$result['defective_resolution_applied'] = false;
$result['defective_candidates'] = array();
$result['defective_resolution_status'] = 'not_implemented';
return $result;
}
}
/**
* ============================================================
* FUNCTION 25
* Resolve Hamzated Roots
* ============================================================
*
* Placeholder for roots containing hamza in any radical slot:
*
* أ-ك-ل
* س-أ-ل
* ق-ر-أ
*
* Hamza is not a weak radical, but its surface seat may vary:
*
* أ إ ؤ ئ ء
*
* Later this function will normalize surface hamza forms to the
* underlying lexical radical ء.
*
* @param array<string, mixed> $analysis
*
* @return array<string, mixed>
*/
if (!function_exists('ace_resolve_hamzated_roots')) {
function ace_resolve_hamzated_roots(array $analysis): array
{
$result = $analysis;
$result['hamzated_resolution_applied'] = false;
$result['hamzated_candidates'] = array();
$result['hamzated_resolution_status'] = 'not_implemented';
return $result;
}
}
/**
* ============================================================
* FUNCTION 26
* Merge Weak Root Candidates
* ============================================================
*
* Combines root candidates generated by:
*
* - structural root extraction
* - mithal reconstruction
* - hollow reconstruction
* - defective reconstruction
* - hamzated normalization
*
* It also performs limited fallback reconstruction when the
* structural analysis contains only two radicals.
*
* Important rules:
*
* 1. Hamza forms أ إ ؤ ئ are normalized to ء.
* 2. Final alif maqsurah ى is normalized to ي.
* 3. A missing final radical generates both و and ي candidates.
* 4. A missing initial radical generates both و and ي candidates.
* 5. Existing candidates are preserved and deduplicated.
* 6. Candidate source and evidence are retained separately.
*
* @param array<string, mixed> $analysis
*
* @return array<string, mixed>
*/
if (!function_exists('ace_merge_weak_root_candidates')) {
function ace_merge_weak_root_candidates(array $analysis): array
{
$result = $analysis;
/*
* --------------------------------------------------------
* Internal helpers.
* --------------------------------------------------------
*/
/**
* Normalize one Arabic radical.
*/
$normalizeRadical = static function ($radical): string {
if (!is_string($radical)) {
return '';
}
$radical = trim($radical);
if ($radical === '') {
return '';
}
/*
* Remove Arabic combining marks.
*/
$radical = preg_replace(
'/[\x{064B}-\x{065F}\x{0670}\x{06D6}-\x{06ED}]/u',
'',
$radical
);
if (!is_string($radical)) {
return '';
}
/*
* Normalize all written hamza forms.
*/
$radical = str_replace(
array('أ', 'إ', 'ؤ', 'ئ'),
'ء',
$radical
);
/*
* Alif maqsurah normally represents final yāʾ at the
* lexical-root level.
*/
if ($radical === 'ى') {
return 'ي';
}
return $radical;
};
/**
* Normalize a complete root candidate.
*/
$normalizeRoot = static function ($root) use (
$normalizeRadical
): string {
if (!is_string($root)) {
return '';
}
$root = trim($root);
if ($root === '') {
return '';
}
/*
* Remove separators used by display-oriented roots.
*/
$root = preg_replace(
'/[\s\-\x{2010}-\x{2015},،\/\\\\|]+/u',
'',
$root
);
if (!is_string($root) || $root === '') {
return '';
}
/*
* Remove Arabic marks before splitting letters.
*/
$root = preg_replace(
'/[\x{064B}-\x{065F}\x{0670}\x{06D6}-\x{06ED}]/u',
'',
$root
);
if (!is_string($root) || $root === '') {
return '';
}
$letters = preg_split(
'//u',
$root,
-1,
PREG_SPLIT_NO_EMPTY
);
if (!is_array($letters)) {
return '';
}
$normalizedLetters = array();
foreach ($letters as $letter) {
$normalizedLetter = $normalizeRadical($letter);
if ($normalizedLetter !== '') {
$normalizedLetters[] = $normalizedLetter;
}
}
return implode('', $normalizedLetters);
};
/**
* Return the Unicode length of a root.
*/
$rootLength = static function (string $root): int {
if (function_exists('mb_strlen')) {
return mb_strlen($root, 'UTF-8');
}
$letters = preg_split(
'//u',
$root,
-1,
PREG_SPLIT_NO_EMPTY
);
return is_array($letters)
? count($letters)
: 0;
};
/*
* Candidate map:
*
* normalized root => supporting sources and evidence
*/
$candidateMap = array();
/**
* Add one candidate with provenance.
*/
$addCandidate = static function (
$candidate,
string $source,
string $evidence = ''
) use (
&$candidateMap,
$normalizeRoot,
$rootLength
): void {
$normalized = $normalizeRoot($candidate);
if ($normalized === '') {
return;
}
$length = $rootLength($normalized);
/*
* Weak-root processing currently accepts triliteral
* and quadriliteral candidates only.
*
* Two-radical structures are handled later by the
* reconstruction fallback.
*/
if ($length !== 3 && $length !== 4) {
return;
}
if (!isset($candidateMap[$normalized])) {
$candidateMap[$normalized] = array(
'root' => $normalized,
'sources' => array(),
'evidence' => array(),
);
}
if ($source !== '') {
$candidateMap[$normalized]['sources'][] = $source;
}
if ($evidence !== '') {
$candidateMap[$normalized]['evidence'][] = $evidence;
}
};
/**
* Add candidates from a field that may contain:
*
* - one root string
* - a list of root strings
* - candidate records containing root/candidate fields
*/
$collectCandidateField = static function (
$value,
string $source
) use (
&$addCandidate
): void {
if (is_string($value)) {
$addCandidate(
$value,
$source,
'Existing root candidate'
);
return;
}
if (!is_array($value)) {
return;
}
foreach ($value as $candidate) {
if (is_string($candidate)) {
$addCandidate(
$candidate,
$source,
'Existing root candidate'
);
continue;
}
if (!is_array($candidate)) {
continue;
}
$candidateRoot = '';
foreach (
array(
'root',
'candidate',
'normalized_root',
'selected_root',
'final_root',
) as $rootKey
) {
if (
isset($candidate[$rootKey])
&& is_string($candidate[$rootKey])
&& trim($candidate[$rootKey]) !== ''
) {
$candidateRoot = $candidate[$rootKey];
break;
}
}
if ($candidateRoot === '') {
continue;
}
$candidateEvidence = '';
if (
isset($candidate['evidence'])
&& is_string($candidate['evidence'])
) {
$candidateEvidence = $candidate['evidence'];
}
$addCandidate(
$candidateRoot,
$source,
$candidateEvidence !== ''
? $candidateEvidence
: 'Existing candidate record'
);
}
};
/*
* --------------------------------------------------------
* 1. Collect complete roots from individual fields.
* --------------------------------------------------------
*/
$singleRootFields = array(
'selected_root',
'final_root',
'root',
'structural_root',
'preliminary_root',
'normalized_root',
'mithal_root',
'hollow_root',
'defective_root',
'hamzated_root',
);
foreach ($singleRootFields as $field) {
if (isset($analysis[$field])) {
$collectCandidateField(
$analysis[$field],
$field
);
}
}
/*
* --------------------------------------------------------
* 2. Collect candidates produced by earlier stages.
* --------------------------------------------------------
*/
$candidateFields = array(
'root_candidates',
'generated_root_candidates',
'validated_roots',
'valid_roots',
'structural_root_candidates',
'mithal_root_candidates',
'hollow_root_candidates',
'defective_root_candidates',
'hamzated_root_candidates',
'weak_initial_candidates',
'weak_medial_candidates',
'weak_final_candidates',
);
foreach ($candidateFields as $field) {
if (isset($analysis[$field])) {
$collectCandidateField(
$analysis[$field],
$field
);
}
}
/*
* --------------------------------------------------------
* 3. Read normalized structural slots.
* --------------------------------------------------------
*/
$r0 = isset($analysis['r0'])
? $normalizeRadical($analysis['r0'])
: '';
$r1 = isset($analysis['r1'])
? $normalizeRadical($analysis['r1'])
: '';
$r2 = isset($analysis['r2'])
? $normalizeRadical($analysis['r2'])
: '';
/*
* A complete structural root should always be retained.
*/
if ($r0 !== '' && $r1 !== '' && $r2 !== '') {
$addCandidate(
$r0 . $r1 . $r2,
'structural_slots',
'Complete r0, r1, and r2 assignment'
);
}
/*
* --------------------------------------------------------
* 4. Reconstruct a neutralized final weak radical.
* --------------------------------------------------------
*
* Examples:
*
* دَعَا structural radicals: د + ع
* يَدْعُونَ structural radicals: د + ع
*
* Surface morphology does not independently establish
* whether the missing final radical is و or ي.
*
* Therefore:
*
* دعو
* دعي
*/
if ($r0 !== '' && $r1 !== '' && $r2 === '') {
$addCandidate(
$r0 . $r1 . 'و',
'defective_fallback',
'Missing r2 reconstructed as و'
);
$addCandidate(
$r0 . $r1 . 'ي',
'defective_fallback',
'Missing r2 reconstructed as ي'
);
}
/*
* --------------------------------------------------------
* 5. Reconstruct a missing initial weak radical.
* --------------------------------------------------------
*
* This is useful when an imperfect form loses initial wāw
* after the person marker.
*
* Both alternatives are retained until lexical or
* conjugational validation resolves them.
*/
if ($r0 === '' && $r1 !== '' && $r2 !== '') {
$addCandidate(
'و' . $r1 . $r2,
'mithal_fallback',
'Missing r0 reconstructed as و'
);
$addCandidate(
'ي' . $r1 . $r2,
'mithal_fallback',
'Missing r0 reconstructed as ي'
);
}
/*
* --------------------------------------------------------
* 6. Handle roots written with final long alif.
* --------------------------------------------------------
*
* If a previously selected root still contains final ا,
* retain both possible lexical weak-radical restorations.
*
* Examples:
*
* دعا -> دعو / دعي
* حيا -> حيو / حيي
*/
$longAlifSources = array(
'selected_root',
'final_root',
'root',
'structural_root',
);
foreach ($longAlifSources as $field) {
if (
!isset($analysis[$field])
|| !is_string($analysis[$field])
) {
continue;
}
$rawRoot = trim($analysis[$field]);
if ($rawRoot === '') {
continue;
}
$rawRoot = preg_replace(
'/[\x{064B}-\x{065F}\x{0670}\x{06D6}-\x{06ED}]/u',
'',
$rawRoot
);
if (!is_string($rawRoot) || $rawRoot === '') {
continue;
}
if (preg_match('/ا$/u', $rawRoot) === 1) {
$base = preg_replace('/ا$/u', '', $rawRoot);
if (is_string($base) && $base !== '') {
$addCandidate(
$base . 'و',
'final_alif_fallback',
'Final ا reconstructed as و'
);
$addCandidate(
$base . 'ي',
'final_alif_fallback',
'Final ا reconstructed as ي'
);
}
}
}
/*
* --------------------------------------------------------
* 7. Finalize candidate details.
* --------------------------------------------------------
*/
foreach ($candidateMap as &$candidateRecord) {
$candidateRecord['sources'] = array_values(
array_unique($candidateRecord['sources'])
);
$candidateRecord['evidence'] = array_values(
array_unique($candidateRecord['evidence'])
);
$candidateRecord['source_count'] = count(
$candidateRecord['sources']
);
}
unset($candidateRecord);
$candidateDetails = array_values($candidateMap);
/*
* Preserve the simple string-list field expected by the
* existing display and validation pipeline.
*/
$mergedCandidates = array_keys($candidateMap);
$result['weak_root_candidates'] = $mergedCandidates;
$result['weak_root_candidate_details'] = $candidateDetails;
$result['weak_root_candidate_count'] = count(
$mergedCandidates
);
/*
* --------------------------------------------------------
* 8. Report merge status.
* --------------------------------------------------------
*/
if (count($mergedCandidates) === 0) {
$result['weak_root_merge_status'] = 'no_candidates';
} elseif (count($mergedCandidates) === 1) {
$result['weak_root_merge_status'] = 'single_candidate';
} else {
$result['weak_root_merge_status'] = 'multiple_candidates';
}
$result['weak_root_merge_complete'] = true;
return $result;
}
}
/**
* ============================================================
* FUNCTION 27
* Score Weak Root Candidates
* ============================================================
*
* Placeholder for scoring lexical root candidates.
*
* Later scoring may use:
*
* - dictionary validation
* - verb-form compatibility
* - surface weak-letter evidence
* - vowel-pattern evidence
* - suffix interaction
* - token family
* - frequency
* - ambiguity penalties
*
* @param array<string, mixed> $analysis
*
* @return array<string, mixed>
*/
if (!function_exists('ace_score_weak_root_candidates')) {
function ace_score_weak_root_candidates(array $analysis): array
{
$result = $analysis;
$candidates =
isset($analysis['weak_root_candidates'])
&& is_array($analysis['weak_root_candidates'])
? $analysis['weak_root_candidates']
: array();
$scoredCandidates = array();
foreach ($candidates as $index => $candidate) {
if (!is_string($candidate)) {
continue;
}
$candidate = trim($candidate);
if ($candidate === '') {
continue;
}
/*
* Temporary neutral score.
*
* The first structural candidate receives no linguistic
* preference. The index is retained only for stable
* ordering.
*/
$scoredCandidates[] = array(
'root' => $candidate,
'score' => 0,
'rank_order' => $index,
'source' => 'placeholder',
'reasons' => array(
'Weak-root scoring is not implemented yet',
),
);
}
$result['scored_weak_root_candidates'] =
$scoredCandidates;
$result['weak_root_scoring_status'] = 'not_implemented';
return $result;
}
}
/**
* ============================================================
* FUNCTION 28
* Select Final Lexical Root
* ============================================================
*
* Selects the final lexical root after all weak-root resolution
* and scoring stages.
*
* Until scoring is implemented, this placeholder uses:
*
* 1. First scored weak-root candidate.
* 2. First merged weak-root candidate.
* 3. selected_root.
* 4. final_root.
*
* @param array<string, mixed> $analysis
*
* @return array<string, mixed>
*/
if (!function_exists('ace_select_final_lexical_root')) {
function ace_select_final_lexical_root(
array $analysis
): array {
$result = $analysis;
$finalLexicalRoot = '';
$selectionSource = '';
/*
* First preference:
* first scored weak-root candidate.
*/
if (
isset($analysis['scored_weak_root_candidates'])
&& is_array($analysis['scored_weak_root_candidates'])
&& !empty($analysis['scored_weak_root_candidates'])
) {
$firstScored =
$analysis['scored_weak_root_candidates'][0];
if (
is_array($firstScored)
&& isset($firstScored['root'])
&& is_string($firstScored['root'])
) {
$finalLexicalRoot = trim(
$firstScored['root']
);
if ($finalLexicalRoot !== '') {
$selectionSource =
'scored_weak_root_candidates';
}
}
}
/*
* Second preference:
* first merged weak-root candidate.
*/
if (
$finalLexicalRoot === ''
&& isset($analysis['weak_root_candidates'])
&& is_array($analysis['weak_root_candidates'])
&& !empty($analysis['weak_root_candidates'])
) {
$firstWeakCandidate =
$analysis['weak_root_candidates'][0];
if (is_string($firstWeakCandidate)) {
$finalLexicalRoot = trim(
$firstWeakCandidate
);
if ($finalLexicalRoot !== '') {
$selectionSource =
'weak_root_candidates';
}
}
}
/*
* Third preference:
* best structural root from Function 20.
*/
if (
$finalLexicalRoot === ''
&& isset($analysis['selected_root'])
&& is_string($analysis['selected_root'])
) {
$finalLexicalRoot = trim(
$analysis['selected_root']
);
if ($finalLexicalRoot !== '') {
$selectionSource = 'selected_root';
}
}
/*
* Final fallback:
* preliminary root from Function 18.
*/
if (
$finalLexicalRoot === ''
&& isset($analysis['final_root'])
&& is_string($analysis['final_root'])
) {
$finalLexicalRoot = trim(
$analysis['final_root']
);
if ($finalLexicalRoot !== '') {
$selectionSource = 'final_root';
}
}
$result['final_lexical_root'] =
$finalLexicalRoot;
$result['final_lexical_root_source'] =
$selectionSource;
$result['final_lexical_root_selected'] =
$finalLexicalRoot !== '';
$result['lexical_root_selection_status'] =
$finalLexicalRoot !== ''
? 'placeholder_selection'
: 'no_candidate';
return $result;
}
}
//------------------------
function ace_chars(string $text): array
{
if ($text === '') {
return [];
}
$characters = preg_split('//u', $text, -1, PREG_SPLIT_NO_EMPTY);
return is_array($characters) ? $characters : [];
}
/**
* ============================================================
* FUNCTION 3
* Remove Arabic diacritics
* ============================================================
*
* Removes Arabic combining marks and tatweel while preserving
* every written base letter, including:
*
* ا و ي ى
* أ إ آ ؤ ئ ء
*
* Important:
*
* - Shadda is removed here, not expanded.
* - Shadda expansion belongs to the structural-skeleton stage.
* - Long-vowel letters are preserved.
* - Weak radicals are not reconstructed here.
* - Hamza forms are not normalized here.
* - Suffix-analysis decisions are not corrected here.
*
* Examples:
*
* تَدَرَّس -> تدرس
* يَتَدَرَّس -> يتدرس
* اِحْمَرّ -> احمر
* قَال -> قال
* يَقُول -> يقول
* قَرَأ -> قرأ
* وَقَى -> وقى
*
* @param string $text Marked Arabic text.
*
* @return string Text containing base letters only.
*/
function ace_remove_diacritics(string $text): string
{
if ($text === '') {
return '';
}
/*
* Remove the main Arabic combining-mark ranges:
*
* U+0610–U+061A Arabic signs
* U+064B–U+065F tanwin, vowels, shadda, sukun, etc.
* U+0670 dagger alif
* U+06D6–U+06ED Qur'anic annotation marks
*
* Remove U+0640 tatweel separately because it is not a
* combining mark.
*/
$plain = preg_replace(
'/[\x{0610}-\x{061A}'
. '\x{064B}-\x{065F}'
. '\x{0670}'
. '\x{06D6}-\x{06ED}'
. '\x{0640}]/u',
'',
$text
);
/*
* preg_replace() may return null if malformed UTF-8 reaches
* the function. Preserve the original input rather than
* causing a return-type error.
*/
return is_string($plain)
? $plain
: $text;
}
function ace_empty_dissection(): array
{
return [
'vn' => '',
'vm' => '',
'r0' => '',
'v1' => '',
'r1' => '',
'r1_repeat' => false,
'v2' => '',
'r2' => '',
'r2_repeat' => false,
'remaining' => '',
];
}
/**
* Build a letter skeleton from a marked Arabic stem.
*
* Arabic combining marks and tatweel are removed. A shadda is
* represented structurally by repeating the consonant that
* carries it.
*
* Examples:
*
* تَدَرَّس → تدررس
* يَتَدَرَّس → يتدررس
* اِحْمَرّ → احمرر
* اِحْمَرَر → احمرر
*
* Long-vowel and weak letters such as ا, و, ي, and ى are
* preserved because they are base letters, not diacritics.
*/
function ace_build_expanded_skeleton(string $text): string
{
$result = '';
$previousBase = '';
$ignoredMarks = [
ACE_FATHATAN,
ACE_DAMMATAN,
ACE_KASRATAN,
ACE_FATHA,
ACE_DAMMA,
ACE_KASRA,
ACE_SUKUN,
ACE_DAGGER_ALIF,
ACE_TATWEEL,
];
foreach (ace_chars($text) as $character) {
/*
* Shadda contributes another structural occurrence of
* the consonant that carries it.
*/
if ($character === ACE_SHADDA) {
if ($previousBase !== '') {
$result .= $previousBase;
}
continue;
}
/*
* Ordinary Arabic marks do not contribute letters or
* structural positions.
*/
if (in_array($character, $ignoredMarks, true)) {
continue;
}
/*
* Preserve the base letter and make it available for a
* following shadda.
*/
$result .= $character;
$previousBase = $character;
}
return $result;
}
/**
* Determine whether a token ends with an exact non-empty Unicode suffix.
*
* This function performs only a surface-level comparison. It does not decide
* whether the matched characters belong grammatically to the stem or suffix.
*/
function ace_ends_with(string $token, string $suffix): bool
{
/*
* ACE does not treat the empty string as a suffix candidate.
*/
if ($suffix === '') {
return false;
}
/*
* Canonically equivalent Unicode strings should compare identically.
*
* Arabic text may contain combining marks encoded in different canonical
* sequences. Normalization requires the PHP intl extension.
*/
if (class_exists('Normalizer')) {
$normalizedToken = Normalizer::normalize(
$token,
Normalizer::FORM_C
);
$normalizedSuffix = Normalizer::normalize(
$suffix,
Normalizer::FORM_C
);
if ($normalizedToken !== false) {
$token = $normalizedToken;
}
if ($normalizedSuffix !== false) {
$suffix = $normalizedSuffix;
}
}
$tokenLength = mb_strlen($token, 'UTF-8');
$suffixLength = mb_strlen($suffix, 'UTF-8');
if ($suffixLength > $tokenLength) {
return false;
}
return mb_substr(
$token,
-$suffixLength,
null,
'UTF-8'
) === $suffix;
}
/**
* Find and remove the longest eligible exact suffix.
*
* Important weak-letter rule:
*
* A single ا, و, or ي is structurally ambiguous because it may
* be:
*
* - a suffix;
* - a long-vowel letter;
* - a weak radical.
*
* When such a candidate is a verbal suffix, its removal must
* leave at least three Arabic base letters. This prevents:
*
* دَعَا -> دَعَ
*
* because the final ا represents the third weak radical.
*
* It still permits:
*
* دَعَوَا -> دَعَوَ
*
* because three base letters د ع و remain.
*
* Empty suffix entries remain fallbacks only.
*
* @param array<int|string, mixed> $suffixCollection
*
* @return array{
* matched_suffix:string,
* matched_entry:?array,
* token_after_suffix:string
* }
*/
function ace_remove_longest_suffix(
string $token,
array $suffixCollection
): array {
$matchedSuffix = '';
$matchedEntry = null;
$matchedStem = $token;
$bestLength = -1;
$bestOrder = PHP_INT_MAX;
/*
* These are combining marks, not Arabic base letters.
* They may be removed without structural restrictions.
*/
$unconstrainedVowelMarks = [
ACE_FATHA,
ACE_KASRA,
ACE_DAMMA,
];
/*
* A single occurrence of one of these letters may be either
* suffixal or part of the lexical stem/root.
*/
$ambiguousWeakLetters = [
'ا',
'و',
'ي',
];
$tokenLength = mb_strlen($token, 'UTF-8');
$initialLetter = ace_first_base_letter($token);
/*
* Use iteration order for deterministic equal-length ties,
* including collections with string keys.
*/
$collectionOrder = 0;
foreach ($suffixCollection as $entry) {
$currentOrder = $collectionOrder;
$collectionOrder++;
if (!is_array($entry)) {
continue;
}
/*
* Accept either `suffix` or the legacy `value` field.
*/
$candidate = null;
if (
array_key_exists('suffix', $entry)
&& is_string($entry['suffix'])
) {
$candidate = $entry['suffix'];
} elseif (
array_key_exists('value', $entry)
&& is_string($entry['value'])
) {
$candidate = $entry['value'];
}
/*
* Empty suffixes are fallback entries and must not
* participate in active suffix matching.
*/
if ($candidate === null || $candidate === '') {
continue;
}
if (!ace_ends_with($token, $candidate)) {
continue;
}
$candidateLength = mb_strlen(
$candidate,
'UTF-8'
);
$remainingLength = $tokenLength - $candidateLength;
if ($remainingLength < 0) {
continue;
}
$remainingToken = mb_substr(
$token,
0,
$remainingLength,
'UTF-8'
);
$remainingBaseLetters = ace_count_base_letters(
$remainingToken
);
$isUnconstrainedVowelMark = in_array(
$candidate,
$unconstrainedVowelMarks,
true
);
$isAmbiguousSingleWeakLetter = (
ace_count_base_letters($candidate) === 1
&& in_array(
ace_first_base_letter($candidate),
$ambiguousWeakLetters,
true
)
);
/*
* Determine whether this suffix entry is explicitly
* classified as verbal.
*
* Support both:
*
* 'pos' => 'verb'
*
* and:
*
* 'pos' => ['verb']
*/
$isVerbEntry = false;
if (array_key_exists('pos', $entry)) {
if (
is_string($entry['pos'])
&& $entry['pos'] === 'verb'
) {
$isVerbEntry = true;
} elseif (
is_array($entry['pos'])
&& in_array('verb', $entry['pos'], true)
) {
$isVerbEntry = true;
}
}
/*
* Hard protection for weak verbal radicals.
*
* A single weak-letter verbal suffix may not be removed
* when fewer than three base letters would remain.
*
* Therefore:
*
* دَعَا
*
* does not lose its final radical ا.
*
* But:
*
* دَعَوَا
*
* may lose the dual suffix ا because د ع و remain.
*/
if (
$isAmbiguousSingleWeakLetter
&& $isVerbEntry
&& $remainingBaseLetters < 3
) {
continue;
}
/*
* A single weak-letter candidate must have some explicit
* structural evidence before it can be selected.
*/
if ($isAmbiguousSingleWeakLetter) {
$hasStructuralControl = (
array_key_exists('min_stem_letters', $entry)
|| array_key_exists('required_initials', $entry)
|| array_key_exists('forbidden_initials', $entry)
|| (
array_key_exists('validator', $entry)
&& is_callable($entry['validator'])
)
);
if (!$hasStructuralControl) {
continue;
}
}
/*
* Final short-vowel marks are safe surface endings.
* Contextual constraints intended for letter suffixes
* are not applied to them.
*/
if (!$isUnconstrainedVowelMark) {
/*
* Optional minimum remaining base-letter count.
*/
if (array_key_exists('min_stem_letters', $entry)) {
$minimumStemLetters = max(
0,
(int) $entry['min_stem_letters']
);
if (
$remainingBaseLetters
< $minimumStemLetters
) {
continue;
}
}
/*
* Optional required initial letters.
*/
if (
array_key_exists('required_initials', $entry)
&& is_array($entry['required_initials'])
&& !in_array(
$initialLetter,
$entry['required_initials'],
true
)
) {
continue;
}
/*
* Optional forbidden initial letters.
*/
if (
array_key_exists('forbidden_initials', $entry)
&& is_array($entry['forbidden_initials'])
&& in_array(
$initialLetter,
$entry['forbidden_initials'],
true
)
) {
continue;
}
/*
* Optional morphology-specific validation.
*/
if (
array_key_exists('validator', $entry)
&& is_callable($entry['validator'])
) {
$isValid = (bool) $entry['validator'](
$token,
$candidate,
$remainingToken,
$entry
);
if (!$isValid) {
continue;
}
}
}
/*
* Prefer the longest eligible exact suffix.
* Original collection order resolves equal-length ties.
*/
if (
$candidateLength > $bestLength
|| (
$candidateLength === $bestLength
&& $currentOrder < $bestOrder
)
) {
$matchedSuffix = $candidate;
$matchedEntry = $entry;
$matchedStem = $remainingToken;
$bestLength = $candidateLength;
$bestOrder = $currentOrder;
}
}
return [
'matched_suffix' => $matchedSuffix,
'matched_entry' => $matchedEntry,
'token_after_suffix' => $matchedStem,
];
}
/**
* Report the first plain letter and its broad onset group.
*
* This function performs only an initial-letter classification.
* It does not determine tense, form, person, or part of speech.
*
* Possible imperfect person-marker letters are:
* ي، ت، أ، ن
*
* Bare alif ا is reported separately because it may represent
* hamzat al-wasl in an imperative, perfect derived form,
* or verbal noun.
*
* Initial م is reported separately as a possible nominal or
* participial marker.
*
* @return array{
* letter:string,
* value:string,
* group:string,
* is_imperfect_group_letter:bool,
* is_bare_alif:bool,
* is_mim_onset:bool
* }
*/
function ace_detect_initial_group_letter(string $plainStem): array
{
$letters = ace_chars($plainStem);
$first = $letters[0] ?? '';
/*
* These are possible imperfect person-marker letters only.
* Their presence does not prove that the token is imperfect.
*/
$imperfectLetters = [
'ي' => 'y',
'ت' => 't',
'أ' => 'a',
'ن' => 'n',
];
$isImperfectLetter = array_key_exists(
$first,
$imperfectLetters
);
if ($isImperfectLetter) {
$group = 'possible_imperfect_person_marker';
$value = $imperfectLetters[$first];
} elseif ($first === 'ا') {
$group = 'bare_alif';
$value = 'a';
} elseif ($first === 'م') {
$group = 'mim_onset';
$value = 'm';
} else {
$group = '';
$value = '';
}
return [
'letter' => $first,
'value' => $value,
'group' => $group,
'is_imperfect_group_letter' => $isImperfectLetter,
'is_bare_alif' => $first === 'ا',
'is_mim_onset' => $first === 'م',
];
}
/*
* ============================================================
* 1. SUFFIX HELPERS
* ============================================================
*/
/**
* Collect unique, non-zero suffix strings and sort them from
* longest to shortest.
*
* The master collection may store the suffix string under
* either `suffix` or `value`.
*
* This function only prepares suffix strings. It does not decide
* whether a suffix is grammatically valid for a particular token,
* nor does it remove stacked suffixes.
*
* Equal-length suffixes retain their original collection order.
*
* @return list<string>
*/
function ace_collect_sorted_suffixes(array $masterSuffixes): array
{
$collected = [];
$seen = [];
foreach ($masterSuffixes as $index => $suffixEntry) {
if (!is_array($suffixEntry)) {
continue;
}
$suffix = null;
if (
array_key_exists('suffix', $suffixEntry)
&& is_string($suffixEntry['suffix'])
) {
$suffix = $suffixEntry['suffix'];
} elseif (
array_key_exists('value', $suffixEntry)
&& is_string($suffixEntry['value'])
) {
$suffix = $suffixEntry['value'];
}
/*
* The zero suffix is a fallback result and must never
* enter the real suffix-matching loop.
*/
if ($suffix === null || $suffix === '') {
continue;
}
/*
* Deduplicate without losing the position of the first
* occurrence. The first entry has precedence when two
* entries contain the same suffix string.
*/
if (isset($seen[$suffix])) {
continue;
}
$seen[$suffix] = true;
$collected[] = [
'suffix' => $suffix,
'length' => mb_strlen($suffix, 'UTF-8'),
'index' => is_int($index)
? $index
: count($collected),
];
}
usort(
$collected,
static function (array $entryA, array $entryB): int {
/*
* Primary order: longest suffix first.
*/
$lengthComparison =
$entryB['length'] <=> $entryA['length'];
if ($lengthComparison !== 0) {
return $lengthComparison;
}
/*
* Secondary order: preserve collection precedence
* for suffixes of equal Unicode length.
*/
return $entryA['index'] <=> $entryB['index'];
}
);
return array_column($collected, 'suffix');
}
/**
* Find the first master suffix entry whose Arabic suffix equals
* the matched suffix.
*/
function ace_find_suffix_entry(
string $matchedSuffix,
array $masterSuffixes
): array {
if ($matchedSuffix === '') {
return [];
}
foreach ($masterSuffixes as $suffixEntry) {
if (
is_array($suffixEntry)
&& isset($suffixEntry['suffix'])
&& is_string($suffixEntry['suffix'])
&& $suffixEntry['suffix'] === $matchedSuffix
) {
return $suffixEntry;
}
}
return [];
}
/**
* Reconstruct a missing final radical for a two-radical stem.
*
* This applies when morphology has neutralized or replaced the
* third radical, as in:
*
* دَعَا
* يَدْعُونَ
*
* @param array<int, string> $radicals
*
* @return array<int, array{
* root:string,
* root_letters:array<int, string>,
* reconstruction:string
* }>
*/
function ace_reconstruct_missing_final_radical(
array $radicals
): array {
if (count($radicals) !== 2) {
return [];
}
[$r0, $r1] = array_values($radicals);
$candidates = [];
foreach (['و', 'ي', 'ء'] as $r2) {
$rootLetters = [$r0, $r1, $r2];
$candidates[] = [
'root' => implode('', $rootLetters),
'root_letters' => $rootLetters,
'reconstruction' => 'missing_final_radical',
];
}
return $candidates;
}
/**
* Read the grammatical type from the matched suffix entry.
*
* The function supports several possible key names so that the
* analyzer remains compatible while the suffix array evolves.
*/
function ace_get_suffix_type(array $suffixEntry): string
{
$possibleKeys = [
'suffix_type',
'type',
'category',
'family',
'tense_mood',
'mood',
];
foreach ($possibleKeys as $key) {
if (
isset($suffixEntry[$key])
&& is_string($suffixEntry[$key])
&& $suffixEntry[$key] !== ''
) {
return $suffixEntry[$key];
}
}
return '';
}
/**
* Remove the longest matching suffix and return all suffix-related
* values together.
*/
function ace_match_longest_suffix(
string $token,
array $masterSuffixes
): array {
$tokenLength = mb_strlen(
$token,
'UTF-8'
);
$matchedSuffix = '';
$matchedSuffixLength = 0;
$matchedSuffixEntry = [];
$markedStem = $token;
$sortedSuffixes = ace_collect_sorted_suffixes(
$masterSuffixes
);
foreach ($sortedSuffixes as $suffix) {
$suffixLength = mb_strlen(
$suffix,
'UTF-8'
);
if ($suffixLength > $tokenLength) {
continue;
}
$tokenEnding = mb_substr(
$token,
-$suffixLength,
null,
'UTF-8'
);
if ($tokenEnding !== $suffix) {
continue;
}
$matchedSuffix = $suffix;
$matchedSuffixLength = $suffixLength;
$markedStem = mb_substr(
$token,
0,
$tokenLength - $suffixLength,
'UTF-8'
);
$matchedSuffixEntry = ace_find_suffix_entry(
$matchedSuffix,
$masterSuffixes
);
break;
}
return [
'matched_suffix' => $matchedSuffix,
'matched_suffix_entry' => $matchedSuffixEntry,
'matched_suffix_type' => ace_get_suffix_type(
$matchedSuffixEntry
),
'matched_suffix_length' => $matchedSuffixLength,
'marked_stem' => $markedStem,
];
}
/*
* ============================================================
* 2. CLUSTER HELPERS
* ============================================================
*/
/**
* Determine whether a character is an Arabic diacritic used by
* the analyzer.
*/
function ace_is_analysis_diacritic(string $character): bool
{
return in_array(
$character,
[
ACE_FATHATAN,
ACE_DAMMATAN,
ACE_KASRATAN,
ACE_FATHA,
ACE_DAMMA,
ACE_KASRA,
ACE_SHADDA,
ACE_SUKUN,
ACE_DAGGER_ALIF,
],
true
);
}
/**
* Convert a marked Arabic string into letter-plus-mark clusters.
*
* Each cluster has this structure:
*
* [
* 'letter' => 'ر',
* 'marks' => ['َ', 'ّ'],
* 'text' => 'رَّ',
* ]
*/
function ace_build_clusters(string $markedText): array
{
$clusters = [];
foreach (ace_chars($markedText) as $character) {
/*
* Tatweel is decorative and has no morphological value.
*/
if ($character === ACE_TATWEEL) {
continue;
}
/*
* Attach a diacritic to the preceding Arabic letter.
*/
if (ace_is_analysis_diacritic($character)) {
$lastIndex = count($clusters) - 1;
/*
* Ignore an unattached mark at the beginning of the text.
*/
if ($lastIndex < 0) {
continue;
}
$clusters[$lastIndex]['marks'][] = $character;
$clusters[$lastIndex]['text'] .= $character;
continue;
}
/*
* Start a new letter cluster.
*/
$clusters[] = [
'letter' => $character,
'marks' => [],
'text' => $character,
];
}
return array_values($clusters);
}
/**
* Return only valid, non-empty letters from a cluster collection.
*/
function ace_cluster_letters(array $clusters): array
{
$letters = [];
foreach ($clusters as $cluster) {
if (!is_array($cluster)) {
continue;
}
$letter = isset($cluster['letter'])
&& is_string($cluster['letter'])
? $cluster['letter']
: '';
if ($letter === '') {
continue;
}
$letters[] = $letter;
}
return $letters;
}
/**
* Safely return the mark array belonging to a cluster.
*/
/**
* Extract Arabic combining marks from one structural cluster.
*
* A cluster may arrive as:
* - a string such as "تَ"
* - an array of Unicode characters
* - an array containing cluster metadata
*/
function ace_cluster_marks($cluster): array
{
/*
* Resolve the cluster into a Unicode string.
*/
if (is_string($cluster)) {
$clusterText = $cluster;
} elseif (is_array($cluster)) {
/*
* Support structured cluster arrays.
*/
if (
isset($cluster['cluster'])
&& is_string($cluster['cluster'])
) {
$clusterText = $cluster['cluster'];
} elseif (
isset($cluster['text'])
&& is_string($cluster['text'])
) {
$clusterText = $cluster['text'];
} elseif (
isset($cluster['value'])
&& is_string($cluster['value'])
) {
$clusterText = $cluster['value'];
} else {
/*
* Support arrays of Unicode characters.
* Ignore nested arrays and other non-string values.
*/
$characters = [];
foreach ($cluster as $value) {
if (is_string($value)) {
$characters[] = $value;
}
}
$clusterText = implode('', $characters);
}
} else {
$clusterText = '';
}
if ($clusterText === '') {
return [];
}
/*
* Extract Arabic vowel marks, sukun, shadda,
* dagger alif and related combining marks.
*/
preg_match_all(
'/[\x{0610}-\x{061A}\x{064B}-\x{065F}\x{0670}\x{06D6}-\x{06ED}]/u',
$clusterText,
$matches
);
if (
!isset($matches[0])
|| !is_array($matches[0])
) {
return [];
}
return array_values(
array_filter(
$matches[0],
static function ($mark): bool {
return is_string($mark)
&& $mark !== '';
}
)
);
}
/**
* Return the principal vowel mark from a cluster.
*/
/**
* Return the first vowel mark found in a cluster.
*
* Accepts either:
* - a Unicode cluster string, such as "تَ"
* - a structured cluster array
*/
function ace_get_cluster_vowel($cluster): string
{
/*
* Structured cluster arrays may already contain a vowel.
*/
if (is_array($cluster)) {
foreach (['vowel', 'vowel_mark', 'harakah'] as $key) {
if (
isset($cluster[$key])
&& is_string($cluster[$key])
&& $cluster[$key] !== ''
) {
return $cluster[$key];
}
}
/*
* Search a marks array.
*/
if (
isset($cluster['marks'])
&& is_array($cluster['marks'])
) {
foreach ($cluster['marks'] as $mark) {
if (
is_string($mark)
&& ace_is_short_vowel_mark($mark)
) {
return $mark;
}
}
}
/*
* Some cluster arrays use numeric elements.
*/
foreach ($cluster as $value) {
if (
is_string($value)
&& ace_is_short_vowel_mark($value)
) {
return $value;
}
}
/*
* Fall back to a textual cluster value.
*/
foreach (['cluster', 'text', 'value', 'raw'] as $key) {
if (
isset($cluster[$key])
&& is_string($cluster[$key])
) {
$cluster = $cluster[$key];
break;
}
}
}
if (!is_string($cluster) || $cluster === '') {
return '';
}
/*
* Arabic short vowels:
* fathah, dammah, kasrah,
* fathatan, dammatan, kasratan.
*/
if (
preg_match(
'/[\x{064B}-\x{0650}]/u',
$cluster,
$matches
) === 1
) {
return $matches[0];
}
return '';
}
/**
* Determine whether a Unicode character is an Arabic short-vowel mark.
*/
function ace_is_short_vowel_mark($mark): bool
{
if (!is_string($mark) || $mark === '') {
return false;
}
return preg_match(
'/^[\x{064B}-\x{0650}]$/u',
$mark
) === 1;
}
/**
* Return the base letter from a cluster.
*/
/**
* Return the consonantal letter stored in a cluster.
*/
//-------------------------
/**
* Convert an ACE value into safe displayable text.
*
* This function should be used only at boundaries where a string
* is required. It preserves useful cluster and mark information.
*/
function ace_value_to_text($value): string
{
if ($value === null) {
return '';
}
if (is_string($value)) {
return $value;
}
if (is_int($value) || is_float($value)) {
return (string) $value;
}
if (is_bool($value)) {
return $value ? 'true' : 'false';
}
if (!is_array($value)) {
return '';
}
/*
* A single structural cluster.
*/
if (
isset($value['text'])
&& is_string($value['text'])
) {
return $value['text'];
}
/*
* A cluster without a prebuilt text field.
*/
if (
isset($value['letter'])
&& is_string($value['letter'])
) {
return $value['letter']
. ace_marks_to_text($value['marks'] ?? '');
}
/*
* A list of marks or a list of clusters.
*/
$parts = [];
foreach ($value as $item) {
$text = ace_value_to_text($item);
if ($text !== '') {
$parts[] = $text;
}
}
return implode('', $parts);
}
/**
* Convert a mark value into a plain diacritic string.
*/
function ace_marks_to_text($marks): string
{
if ($marks === null) {
return '';
}
if (is_string($marks)) {
return $marks;
}
if (!is_array($marks)) {
return '';
}
$result = '';
foreach ($marks as $mark) {
if (is_string($mark)) {
$result .= $mark;
continue;
}
if (is_array($mark)) {
$result .= ace_marks_to_text($mark);
}
}
return $result;
}
/**
* Return the base letter from a cluster, letter, or scalar value.
*/
function ace_cluster_letter($cluster): string
{
if (is_string($cluster)) {
return $cluster;
}
if (!is_array($cluster)) {
return '';
}
if (
isset($cluster['letter'])
&& is_string($cluster['letter'])
) {
return $cluster['letter'];
}
if (
isset($cluster['text'])
&& is_string($cluster['text'])
) {
return ace_remove_diacritics($cluster['text']);
}
return '';
}
/**
* Return the marks belonging to a cluster.
*/
function ace_cluster_marks_safe($cluster): string
{
if (!is_array($cluster)) {
return '';
}
return ace_marks_to_text(
$cluster['marks'] ?? ''
);
}
/**
* Return all marks in a cluster as one Unicode string.
*/
function ace_cluster_marks_text($cluster): string
{
if (!is_array($cluster)) {
return '';
}
$marks = $cluster['marks'] ?? '';
if (is_string($marks)) {
return $marks;
}
if (is_array($marks)) {
$cleanMarks = [];
foreach ($marks as $mark) {
if (is_string($mark)) {
$cleanMarks[] = $mark;
}
}
return implode('', $cleanMarks);
}
return '';
}
//--------------------
/**
* Convert any ACE cluster representation into its base letter.
*/
function ace_cluster_letter_value($cluster): string
{
if (is_array($cluster)) {
$letter = $cluster['letter'] ?? '';
return is_scalar($letter)
? (string) $letter
: '';
}
return is_scalar($cluster)
? (string) $cluster
: '';
}
/**
* Convert cluster marks into a normalized array of strings.
*/
function ace_cluster_marks_array($cluster): array
{
if (!is_array($cluster)) {
return [];
}
$marks = $cluster['marks'] ?? [];
if (is_string($marks)) {
return $marks === '' ? [] : [$marks];
}
if (!is_array($marks)) {
return [];
}
$normalized = [];
foreach ($marks as $mark) {
if (is_scalar($mark) && (string) $mark !== '') {
$normalized[] = (string) $mark;
}
}
return $normalized;
}
/**
* Convert cluster marks into one printable string.
*/
function ace_cluster_marks_string($cluster): string
{
return implode(
'',
ace_cluster_marks_array($cluster)
);
}
/**
* Convert an ACE cluster into its complete written text.
*/
function ace_cluster_text_value($cluster): string
{
if (is_array($cluster)) {
$text = $cluster['text'] ?? '';
if (is_scalar($text) && (string) $text !== '') {
return (string) $text;
}
return ace_cluster_letter_value($cluster)
. ace_cluster_marks_string($cluster);
}
return is_scalar($cluster)
? (string) $cluster
: '';
}
/**
* Safely convert a mixed ACE value for HTML/table output.
*/
function ace_display_value($value): string
{
if ($value === null) {
return '';
}
if (is_bool($value)) {
return $value ? 'true' : 'false';
}
if (is_scalar($value)) {
return (string) $value;
}
if (!is_array($value)) {
return '';
}
/*
* A single structural cluster.
*/
if (
array_key_exists('letter', $value) ||
array_key_exists('marks', $value) ||
array_key_exists('text', $value)
) {
return ace_cluster_text_value($value);
}
/*
* A normal indexed or associative array.
*/
$parts = [];
foreach ($value as $key => $item) {
$display = ace_display_value($item);
if ($display === '') {
continue;
}
if (is_string($key)) {
$parts[] = $key . '=' . $display;
} else {
$parts[] = $display;
}
}
return implode(' | ', $parts);
}
/**
* Return all marks belonging to a cluster as one string.
*/
function ace_cluster_marks_value($cluster): string
{
if (!is_array($cluster)) {
return '';
}
$marks = $cluster['marks'] ?? '';
if (is_array($marks)) {
$marks = array_filter(
$marks,
static function ($mark): bool {
return is_string($mark) && $mark !== '';
}
);
return implode('', $marks);
}
return is_string($marks)
? $marks
: '';
}
/**
* Convert an arbitrary scalar or list into printable text.
*/
//---------------------
/**
* Safely convert any ACE value into displayable text.
*/
/**
* Return only combining marks from a marks array or scalar value.
*/
function ace_marks_to_string(mixed $marks): string
{
if (is_array($marks)) {
$marks = array_values(
array_filter(
$marks,
static fn ($mark): bool =>
is_scalar($mark) && (string) $mark !== ''
)
);
return implode('', array_map('strval', $marks));
}
return is_scalar($marks) ? (string) $marks : '';
}
function ace_printable_value($value, string $separator = ' | '): string
{
if ($value === null) {
return '';
}
if (is_bool($value)) {
return $value
? 'true'
: 'false';
}
if (is_string($value)) {
return $value;
}
if (is_int($value) || is_float($value)) {
return (string) $value;
}
if (!is_array($value)) {
return '';
}
$parts = [];
foreach ($value as $key => $item) {
if (
is_array($item)
&& (
array_key_exists('letter', $item)
|| array_key_exists('text', $item)
)
) {
$text = ace_cluster_text_value($item);
if ($text !== '') {
$parts[] = $text;
}
continue;
}
$text = ace_printable_value(
$item,
$separator
);
if ($text === '') {
continue;
}
if (!is_int($key)) {
$parts[] = $key . '=' . $text;
} else {
$parts[] = $text;
}
}
return implode(
$separator,
$parts
);
}
/**
* Rebuild one marked cluster as an Arabic string.
*/
/**
* Rebuild several marked clusters as one Arabic string.
*/
function ace_clusters_to_string(array $clusters): string
{
$output = '';
foreach ($clusters as $cluster) {
if (!is_array($cluster)) {
continue;
}
$output .= ace_cluster_to_string(
$cluster
);
}
return $output;
}
/*
* ============================================================
* 3. STRUCTURAL MARKER HELPERS
* ============================================================
*/
/**
* Remove and report structural form markers.
*
* The function works on a copy of the cluster collection. It does
* not modify marked_stem or structural_stem.
*/
function ace_extract_form_markers(
array $clusters,
string $existingVn = ''
): array {
$workingClusters = array_values(
$clusters
);
$vn = $existingVn;
$vm = '';
$fs = '';
$letters = ace_cluster_letters(
$workingClusters
);
/*
* Fallback imperfect-marker detection.
*
* Normally ace_extract_initial_markers() supplies vn and
* removes it from structural_stem. This block is retained for
* compatibility when the helper does not remove it.
*/
if (
$vn === ''
&& isset($letters[0], $letters[1])
&& in_array(
$letters[0],
['ي', 'ت', 'أ', 'ا', 'ن', 'م'],
true
)
) {
$secondLetter = $letters[1];
$isClearImperfectStructure = false;
if (
in_array(
$secondLetter,
['ت', 'ن'],
true
)
) {
$isClearImperfectStructure = true;
}
if (
$secondLetter === 'س'
&& isset($letters[2])
&& $letters[2] === 'ت'
) {
$isClearImperfectStructure = true;
}
if (
count($letters) === 4
|| count($letters) === 5
) {
$isClearImperfectStructure = true;
}
if ($isClearImperfectStructure) {
$vn = $letters[0];
array_shift(
$workingClusters
);
}
}
$letters = ace_cluster_letters(
$workingClusters
);
/*
* Form X perfect:
*
* اِسْتَدْرَس
*/
if (
isset($letters[0], $letters[1], $letters[2])
&& $letters[0] === 'ا'
&& $letters[1] === 'س'
&& $letters[2] === 'ت'
) {
$formClusters = array_slice(
$workingClusters,
0,
3
);
$fs = ace_clusters_to_string(
$formClusters
);
array_splice(
$workingClusters,
0,
3
);
} elseif (
isset($letters[0], $letters[1])
&& $letters[0] === 'س'
&& $letters[1] === 'ت'
&& $vn !== ''
) {
/*
* Form X imperfect after vn has already been removed:
*
* يَسْتَدْرِس -> سْتَدْرِس
*/
$formClusters = array_slice(
$workingClusters,
0,
2
);
$fs = ace_clusters_to_string(
$formClusters
);
array_splice(
$workingClusters,
0,
2
);
}
$letters = ace_cluster_letters(
$workingClusters
);
/*
* Form VII perfect:
*
* اِنْدَرَس
*/
if (
$fs === ''
&& isset($letters[0], $letters[1])
&& $letters[0] === 'ا'
&& $letters[1] === 'ن'
) {
$formClusters = array_slice(
$workingClusters,
0,
2
);
$fs = ace_clusters_to_string(
$formClusters
);
array_splice(
$workingClusters,
0,
2
);
} elseif (
$fs === ''
&& isset($letters[0])
&& $letters[0] === 'ن'
&& $vn !== ''
) {
/*
* Form VII imperfect after vn removal:
*
* يَنْدَرِس -> نْدَرِس
*/
$fs = ace_cluster_to_string(
$workingClusters[0]
);
array_shift(
$workingClusters
);
}
$letters = ace_cluster_letters(
$workingClusters
);
/*
* Initial alif of Forms VIII and IX.
*/
if (
$fs === ''
&& isset($letters[0])
&& $letters[0] === 'ا'
) {
$fs = ace_cluster_to_string(
$workingClusters[0]
);
array_shift(
$workingClusters
);
}
$letters = ace_cluster_letters(
$workingClusters
);
/*
* Forms V and VI derivational ت.
*/
if (
isset($letters[0])
&& $letters[0] === 'ت'
&& count($workingClusters) >= 4
) {
$vm = ace_cluster_to_string(
$workingClusters[0]
);
array_shift(
$workingClusters
);
}
return [
'vn' => $vn,
'vm' => $vm,
'fs' => $fs,
'structural_clusters' => array_values(
$workingClusters
),
];
}
/*
* ============================================================
* 4. ROOT-CANDIDATE HELPERS
* ============================================================
*/
/**
* Remove long-vowel clusters from strong-root candidates.
*/
function ace_filter_root_candidates(array $clusters): array
{
$rootCandidates = [];
foreach ($clusters as $cluster) {
if (!is_array($cluster)) {
continue;
}
$letter = (string) (
$cluster['letter']
?? ''
);
if (
in_array(
$letter,
['ا', 'و', 'ي', 'ى'],
true
)
) {
continue;
}
$rootCandidates[] = $cluster;
}
return array_values(
$rootCandidates
);
}
/**
* Determine whether four consonant clusters represent the
* standard non-fully-assimilated Form VIII arrangement:
*
* r0 + inserted marker + r1 + r2
*/
function ace_detect_form_eight_candidates(
array $rootCandidates
): array {
$candidateCount = count(
$rootCandidates
);
if ($candidateCount < 4) {
return [
'is_form_eight' => false,
];
}
/*
* Use the final four consonantal clusters. This protects the
* analysis if an earlier non-root marker remains present.
*/
$fourCandidates = array_slice(
$rootCandidates,
-4
);
$candidateR0 = $fourCandidates[0] ?? [];
$marker = $fourCandidates[1] ?? [];
$candidateR1 = $fourCandidates[2] ?? [];
$candidateR2 = $fourCandidates[3] ?? [];
$r0Letter = (string) (
$candidateR0['letter']
?? ''
);
$markerLetter = (string) (
$marker['letter']
?? ''
);
$r1Letter = (string) (
$candidateR1['letter']
?? ''
);
$r2Letter = (string) (
$candidateR2['letter']
?? ''
);
/*
* Common written realizations of the Form VIII marker.
*/
$isPossibleMarker = in_array(
$markerLetter,
['ت', 'ط', 'د'],
true
);
$r0Marks = ace_cluster_marks(
$candidateR0
);
$hasSukunBeforeMarker = in_array(
ACE_SUKUN,
$r0Marks,
true
);
$isFormEight =
$r0Letter !== ''
&& $r1Letter !== ''
&& $r2Letter !== ''
&& $isPossibleMarker
&& $hasSukunBeforeMarker;
if (!$isFormEight) {
return [
'is_form_eight' => false,
];
}
return [
'is_form_eight' => true,
'marker' => $marker,
'marker_letter' => $markerLetter,
'root_clusters' => [
$candidateR0,
$candidateR1,
$candidateR2,
],
];
}
/**
* Perform root extraction, including the special four-cluster
* Form VIII arrangement.
*/
function ace_extract_root_analysis(
array $structuralClusters,
string $currentVm = '',
string $currentFs = ''
): array {
$rootCandidates = ace_filter_root_candidates(
$structuralClusters
);
/*
* Form VIII must be checked before reducing the candidate
* collection to three clusters.
*/
$formEight = ace_detect_form_eight_candidates(
$rootCandidates
);
$vm = $currentVm;
$fs = $currentFs;
if (
($formEight['is_form_eight'] ?? false) === true
) {
$rootClusters = $formEight['root_clusters'] ?? [];
/*
* Keep the actual marked form sign when it is available.
* Otherwise use the form number as a structural label.
*/
if ($fs === '') {
$fs = 'VIII';
}
$marker = $formEight['marker'] ?? [];
if (is_array($marker)) {
$vm = ace_cluster_to_string(
$marker
);
}
} else {
$rootClusters = $rootCandidates;
if (count($rootClusters) > 3) {
$rootClusters = array_slice(
$rootClusters,
-3
);
}
}
$positions = ace_assign_root_positions(
$rootClusters
);
$positions['vm'] = $vm;
$positions['fs'] = $fs;
return $positions;
}
/*
* ============================================================
* 5. MAIN ANALYZER
* ============================================================
*/
function ace_simple_analyze(string $token): array
{
global $master_suffixes, $master_prefixes;
/*
* --------------------------------------------------------
* Step 1: token and suffix analysis
* --------------------------------------------------------
*/
$tokenLength = mb_strlen(
$token,
'UTF-8'
);
$suffixAnalysis = ace_match_longest_suffix(
$token,
$master_suffixes
);
$matchedSuffix = (string) (
$suffixAnalysis['matched_suffix']
?? ''
);
$matchedSuffixEntry = is_array(
$suffixAnalysis['matched_suffix_entry']
?? null
)
? $suffixAnalysis['matched_suffix_entry']
: [];
$suffixType = (string) (
$suffixAnalysis['matched_suffix_type']
?? ''
);
$matchedSuffixLength = (int) (
$suffixAnalysis['matched_suffix_length']
?? 0
);
$markedStem = (string) (
$suffixAnalysis['marked_stem']
?? $token
);
$tokenLengthAfterSuffixRemoval = mb_strlen(
$markedStem,
'UTF-8'
);
/*
* --------------------------------------------------------
* Step 2: extract conjunction, future marker, and vn
* --------------------------------------------------------
*
* Prefixes are reported, but they are not removed here.
* The only removals performed before this stage are suffix
* removal and the structural handling performed by
* ace_extract_initial_markers().
*/
$initialMarkers = ace_extract_initial_markers(
$markedStem
);
$conjunction = (string) (
$initialMarkers['conjunction']
?? ''
);
$futureMarker = (string) (
$initialMarkers['future_marker']
?? ''
);
$vn = (string) (
$initialMarkers['vn']
?? ''
);
$structuralStem = (string) (
$initialMarkers['structural_stem']
?? $markedStem
);
/*
* --------------------------------------------------------
* Step 3: prefix analysis
* --------------------------------------------------------
*
* Prefix matching is informational only. It must not modify
* marked_stem or structural_stem.
*/
$prefixAnalysis = [];
if (
isset($master_prefixes)
&& is_array($master_prefixes)
&& !empty($master_prefixes)
) {
$prefixAnalysis = ace_match_longest_prefix(
$markedStem,
$master_prefixes
);
if (!is_array($prefixAnalysis)) {
$prefixAnalysis = [];
}
}
/*
* --------------------------------------------------------
* Safely collect the matched prefix string
* --------------------------------------------------------
*/
$rawMatchedPrefix = (
$prefixAnalysis['matched_prefix']
?? ''
);
$matchedPrefix = is_string($rawMatchedPrefix)
? $rawMatchedPrefix
: '';
/*
* --------------------------------------------------------
* Collect the primary matched entry
* --------------------------------------------------------
*/
$rawMatchedPrefixEntry = (
$prefixAnalysis['matched_prefix_entry']
?? []
);
$matchedPrefixEntry = is_array($rawMatchedPrefixEntry)
? $rawMatchedPrefixEntry
: [];
/*
* --------------------------------------------------------
* Collect all entries sharing the longest prefix
* --------------------------------------------------------
*/
$rawMatchedPrefixEntries = (
$prefixAnalysis['matched_prefix_entries']
?? []
);
$matchedPrefixEntries = is_array($rawMatchedPrefixEntries)
? $rawMatchedPrefixEntries
: [];
/*
* Compatibility fallback:
*
* Some versions of ace_match_longest_prefix() may return only
* matched_prefix_entry instead of matched_prefix_entries.
*/
if (
empty($matchedPrefixEntries)
&& !empty($matchedPrefixEntry)
) {
$matchedPrefixEntries = [
$matchedPrefixEntry,
];
}
/*
* Remove malformed entries.
*/
$matchedPrefixEntries = array_values(
array_filter(
$matchedPrefixEntries,
static function ($entry): bool {
return is_array($entry);
}
)
);
/*
* --------------------------------------------------------
* Determine matched prefix length
* --------------------------------------------------------
*/
$rawMatchedPrefixLength = (
$prefixAnalysis['matched_prefix_length']
?? null
);
if (is_int($rawMatchedPrefixLength)) {
$matchedPrefixLength = $rawMatchedPrefixLength;
} elseif (
is_numeric($rawMatchedPrefixLength)
&& !is_array($rawMatchedPrefixLength)
) {
$matchedPrefixLength = (int) $rawMatchedPrefixLength;
} else {
$matchedPrefixLength = $matchedPrefix !== ''
? mb_strlen($matchedPrefix, 'UTF-8')
: 0;
}
/*
* --------------------------------------------------------
* Step 4: collect prefix information
* --------------------------------------------------------
*
* Several entries may contain the same Arabic prefix while
* carrying different grammatical classifications.
*/
$prefixFamilies = [];
$prefixFormCandidates = [];
$prefixTenseCandidates = [];
$prefixVoiceCandidates = [];
$prefixMoodCandidates = [];
foreach ($matchedPrefixEntries as $prefixEntry) {
/*
* This check is retained in case malformed data is introduced
* after the filtering operation above.
*/
if (!is_array($prefixEntry)) {
continue;
}
$rawFamily = (
$prefixEntry['family']
?? ''
);
$family = is_string($rawFamily)
? $rawFamily
: '';
$rawForm = (
$prefixEntry['form']
?? $family
);
$form = is_string($rawForm)
? $rawForm
: '';
$rawTense = (
$prefixEntry['tense']
?? ''
);
$tense = is_string($rawTense)
? $rawTense
: '';
$rawVoice = (
$prefixEntry['voice']
?? ''
);
$voice = is_string($rawVoice)
? $rawVoice
: '';
$rawMood = (
$prefixEntry['mood']
?? ''
);
$mood = is_string($rawMood)
? $rawMood
: '';
if ($family !== '') {
$prefixFamilies[] = $family;
}
if ($form !== '') {
$prefixFormCandidates[] = $form;
}
if ($tense !== '') {
$prefixTenseCandidates[] = $tense;
}
if ($voice !== '') {
$prefixVoiceCandidates[] = $voice;
}
if ($mood !== '') {
$prefixMoodCandidates[] = $mood;
}
}
/*
* Remove duplicate grammatical classifications.
*/
$prefixFamilies = array_values(
array_unique($prefixFamilies)
);
$prefixFormCandidates = array_values(
array_unique($prefixFormCandidates)
);
$prefixTenseCandidates = array_values(
array_unique($prefixTenseCandidates)
);
$prefixVoiceCandidates = array_values(
array_unique($prefixVoiceCandidates)
);
$prefixMoodCandidates = array_values(
array_unique($prefixMoodCandidates)
);
/*
* Consolidated prefix information.
*/
$prefixInformation = [
'matched_prefix' => $matchedPrefix,
'matched_prefix_length' => $matchedPrefixLength,
'matched_prefix_entry' => $matchedPrefixEntry,
'matched_prefix_entries' => $matchedPrefixEntries,
'prefix_families' => $prefixFamilies,
'prefix_form_candidates' => $prefixFormCandidates,
'prefix_tense_candidates' => $prefixTenseCandidates,
'prefix_voice_candidates' => $prefixVoiceCandidates,
'prefix_mood_candidates' => $prefixMoodCandidates,
];
/*
* --------------------------------------------------------
* Step 5: basic stem representations
* --------------------------------------------------------
*/
$plainStem = ace_remove_diacritics(
$markedStem
);
$plainStemLength = mb_strlen(
$plainStem,
'UTF-8'
);
$expandedSkeleton = ace_build_expanded_skeleton(
$markedStem
);
$letters = ace_chars(
$expandedSkeleton
);
$initial = ace_detect_initial_group_letter(
$plainStem
);
/*
* --------------------------------------------------------
* Step 6: clusters
* --------------------------------------------------------
*
* clusters contains the complete marked stem for debugging.
*
* structuralClusters begins with structural_stem so that
* conjunctions, future markers, and an extracted imperfect
* marker do not become root candidates.
*/
$clusters = ace_build_clusters(
$markedStem
);
$structuralClusters = ace_build_clusters(
$structuralStem
);
/*
* --------------------------------------------------------
* Step 7: form markers
* --------------------------------------------------------
*/
$formMarkers = ace_extract_form_markers(
$structuralClusters,
$vn
);
$vn = (string) (
$formMarkers['vn']
?? $vn
);
$vm = (string) (
$formMarkers['vm']
?? ''
);
$fs = (string) (
$formMarkers['fs']
?? ''
);
$rootStructuralClusters = is_array(
$formMarkers['structural_clusters']
?? null
)
? $formMarkers['structural_clusters']
: $structuralClusters;
/*
* --------------------------------------------------------
* Step 8: root extraction
* --------------------------------------------------------
*/
$rootAnalysis = ace_extract_root_analysis(
$rootStructuralClusters,
$vm,
$fs
);
if (!is_array($rootAnalysis)) {
$rootAnalysis = [];
}
$vm = (string) (
$rootAnalysis['vm']
?? $vm
);
$fs = (string) (
$rootAnalysis['fs']
?? $fs
);
/*
* --------------------------------------------------------
* Step 9: combine grammar candidates
* --------------------------------------------------------
*
* Prefix candidates and suffix candidates are both retained.
* These are candidate values rather than final classifications.
*/
$suffixFormCandidates = [];
$suffixTenseCandidates = [];
$suffixVoiceCandidates = [];
$suffixMoodCandidates = [];
if (!empty($matchedSuffixEntry)) {
$suffixFamily = (string) (
$matchedSuffixEntry['family']
?? ''
);
$suffixForm = (string) (
$matchedSuffixEntry['form']
?? ''
);
$suffixTense = (string) (
$matchedSuffixEntry['tense']
?? ''
);
$suffixVoice = (string) (
$matchedSuffixEntry['voice']
?? ''
);
$suffixMood = (string) (
$matchedSuffixEntry['mood']
?? $suffixType
);
if ($suffixFamily !== '') {
$suffixFormCandidates[] = $suffixFamily;
}
if ($suffixForm !== '') {
$suffixFormCandidates[] = $suffixForm;
}
if ($suffixTense !== '') {
$suffixTenseCandidates[] = $suffixTense;
}
if ($suffixVoice !== '') {
$suffixVoiceCandidates[] = $suffixVoice;
}
if ($suffixMood !== '') {
$suffixMoodCandidates[] = $suffixMood;
}
}
/*
* Some suffix entries may already provide candidate arrays.
*/
$suffixCandidateFields = [
'form_candidates' => &$suffixFormCandidates,
'tense_candidates' => &$suffixTenseCandidates,
'voice_candidates' => &$suffixVoiceCandidates,
'mood_candidates' => &$suffixMoodCandidates,
];
foreach (
$suffixCandidateFields
as $fieldName => &$destination
) {
$values = $matchedSuffixEntry[$fieldName]
?? [];
if (is_string($values) && $values !== '') {
$destination[] = $values;
} elseif (is_array($values)) {
foreach ($values as $value) {
if (
is_string($value)
&& $value !== ''
) {
$destination[] = $value;
}
}
}
}
unset($destination);
$grammarCandidates = [
'form_candidates' => array_values(
array_unique(
array_merge(
$prefixInformation[
'prefix_form_candidates'
],
$suffixFormCandidates
)
)
),
'tense_candidates' => array_values(
array_unique(
array_merge(
$prefixInformation[
'prefix_tense_candidates'
],
$suffixTenseCandidates
)
)
),
'voice_candidates' => array_values(
array_unique(
array_merge(
$prefixInformation[
'prefix_voice_candidates'
],
$suffixVoiceCandidates
)
)
),
'mood_candidates' => array_values(
array_unique(
array_merge(
$prefixInformation[
'prefix_mood_candidates'
],
$suffixMoodCandidates
)
)
),
];
/*
* --------------------------------------------------------
* Step 10: return complete analysis
* --------------------------------------------------------
*/
return [
/*
* ====================================================
* 1. ORIGINAL TOKEN
* ====================================================
*/
'token' => $token,
'token_length' => $tokenLength,
/*
* ====================================================
* 2. SUFFIX ANALYSIS
* ====================================================
*/
'matched_suffix' => $matchedSuffix,
'matched_suffix_entry' => $matchedSuffixEntry,
'matched_suffix_type' => $suffixType,
'matched_suffix_length' => $matchedSuffixLength,
/*
* ====================================================
* 3. PREFIX ANALYSIS
* ====================================================
*/
'matched_prefix' => $matchedPrefix,
'matched_prefix_entry' => $matchedPrefixEntry,
'matched_prefix_length' => $matchedPrefixLength,
'matched_prefix_entries' => $matchedPrefixEntries,
'prefix_families' =>
$prefixInformation['prefix_families'],
'prefix_form_candidates' =>
$prefixInformation[
'prefix_form_candidates'
],
'prefix_tense_candidates' =>
$prefixInformation[
'prefix_tense_candidates'
],
'prefix_voice_candidates' =>
$prefixInformation[
'prefix_voice_candidates'
],
'prefix_mood_candidates' =>
$prefixInformation[
'prefix_mood_candidates'
],
/*
* ====================================================
* 4. INITIAL PARTICLES
* ====================================================
*/
'conjunction' => $conjunction,
'future_marker' => $futureMarker,
/*
* ====================================================
* 5. STEM INFORMATION
* ====================================================
*/
'marked_stem' => $markedStem,
'plain_stem' => $plainStem,
'structural_stem' => $structuralStem,
'expanded_skeleton' => $expandedSkeleton,
'token_length_after_suffix_removal' =>
$tokenLengthAfterSuffixRemoval,
'plain_stem_length' => $plainStemLength,
'letters' => is_array($letters)
? $letters
: [],
'initial_letter' => (string) (
$initial['letter']
?? ''
),
'initial_group_value' => (string) (
$initial['value']
?? ''
),
'is_imperfect_group' => (bool) (
$initial['is_imperfect_group_letter']
?? false
),
/*
* ====================================================
* 6. STRUCTURAL MARKERS
* ====================================================
*/
'vn' => $vn,
'vm' => $vm,
'fs' => $fs,
/*
* ====================================================
* 7. ROOT AND VOWEL POSITIONS
* ====================================================
*/
'r0' => (string) (
$rootAnalysis['r0']
?? ''
),
'v1' => (string) (
$rootAnalysis['v1']
?? ''
),
'r1' => (string) (
$rootAnalysis['r1']
?? ''
),
'v2' => (string) (
$rootAnalysis['v2']
?? ''
),
'r2' => (string) (
$rootAnalysis['r2']
?? ''
),
/*
* ====================================================
* 8. SHADDA AND REPEATED ROOT LETTERS
* ====================================================
*/
'r1_repeat' => (bool) (
$rootAnalysis['r1_repeat']
?? false
),
'r2_repeat' => (bool) (
$rootAnalysis['r2_repeat']
?? false
),
/*
* ====================================================
* 9. RAW ROOT MARK ARRAYS
* ====================================================
*/
'r0_marks' => is_array(
$rootAnalysis['r0_marks']
?? null
)
? $rootAnalysis['r0_marks']
: [],
'r1_marks' => is_array(
$rootAnalysis['r1_marks']
?? null
)
? $rootAnalysis['r1_marks']
: [],
'r2_marks' => is_array(
$rootAnalysis['r2_marks']
?? null
)
? $rootAnalysis['r2_marks']
: [],
/*
* ====================================================
* 10. ROOT ANALYSIS
* ====================================================
*/
'root' => (string) (
$rootAnalysis['root']
?? ''
),
'root_candidates' => is_array(
$rootAnalysis['root_candidates']
?? null
)
? $rootAnalysis['root_candidates']
: [],
/*
* ====================================================
* 11. COMBINED GRAMMAR CANDIDATES
* ====================================================
*/
'form_candidates' =>
$grammarCandidates['form_candidates'],
'tense_candidates' =>
$grammarCandidates['tense_candidates'],
'voice_candidates' =>
$grammarCandidates['voice_candidates'],
'mood_candidates' =>
$grammarCandidates['mood_candidates'],
/*
* ====================================================
* 12. CLUSTER DEBUGGING
* ====================================================
*/
'clusters' => is_array($clusters)
? $clusters
: [],
'structural_clusters' => is_array(
$rootStructuralClusters
)
? $rootStructuralClusters
: [],
];
}
/*
* Compatibility helpers retained for older ACE test scripts.
*/
/**
* Determine the grammatical type represented by a matched suffix.
*
* Possible return values:
* perfect
* indicative
* subjunctive
* jussive
* imperative
* declension
* ''
*/
function ace_detect_suffix_type(array $suffixEntry): string
{
$families = $suffixEntry['families'] ?? [];
$moods = $suffixEntry['moods'] ?? [];
/*
* Normalize scalar values to arrays.
*/
if (!is_array($families)) {
$families = [$families];
}
if (!is_array($moods)) {
$moods = [$moods];
}
/*
* Normalize all values to lowercase strings.
*/
$families = array_map(
static function ($value): string {
return strtolower(trim((string) $value));
},
$families
);
$moods = array_map(
static function ($value): string {
return strtolower(trim((string) $value));
},
$moods
);
/*
* Imperfect suffixes are classified by mood.
*/
if (in_array('indicative', $moods, true)) {
return 'indicative';
}
if (in_array('subjunctive', $moods, true)) {
return 'subjunctive';
}
if (in_array('jussive', $moods, true)) {
return 'jussive';
}
/*
* The remaining categories are generally stored as families.
*/
if (in_array('perfect', $families, true)) {
return 'perfect';
}
if (in_array('imperative', $families, true)) {
return 'imperative';
}
if (in_array('declension', $families, true)) {
return 'declension';
}
/*
* Allow these values to be stored in moods as well.
*/
if (in_array('imperative', $moods, true)) {
return 'imperative';
}
if (in_array('declension', $moods, true)) {
return 'declension';
}
return '';
}
function ace_find_suffix(string $token, array $suffixes): array
{
$result = ace_remove_longest_suffix($token, $suffixes);
return $result['matched_entry'] ?? ['suffix' => ''];
}
function ace_remove_suffix(string $token, string $suffix): string
{
if ($suffix === '' || !ace_ends_with($token, $suffix)) {
return $token;
}
$stemLength = mb_strlen($token, 'UTF-8')
- mb_strlen($suffix, 'UTF-8');
return mb_substr($token, 0, $stemLength, 'UTF-8');
}
function ace_remove_marks(string $text): string
{
return ace_remove_diacritics($text);
}
//------------------------
/**
* Convert any analyzer result value into a displayable string.
*
* Arrays are JSON-encoded to prevent array-to-string warnings.
*/
function ace_display_string($value): string
{
if ($value === null) {
return '';
}
if (is_string($value)) {
return $value;
}
if (
is_int($value)
|| is_float($value)
|| is_bool($value)
) {
return (string) $value;
}
if (is_array($value)) {
$encoded = json_encode(
$value,
JSON_UNESCAPED_UNICODE
| JSON_UNESCAPED_SLASHES
);
return is_string($encoded)
? $encoded
: '';
}
if (
is_object($value)
&& method_exists($value, '__toString')
) {
return (string) $value;
}
return '';
}
/**
* Escape a value for safe HTML display.
*/
function ace_display_html($value): string
{
return htmlspecialchars(
ace_display_string($value),
ENT_QUOTES,
'UTF-8'
);
}
/**
* Separate optional conjunction and future markers from the beginning
* of a marked Arabic stem.
*
* This function is for structural reporting only. It does not alter
* the original token or the marked stem used by the analyzer.
*/
//function step 1. function ace extract initial markers
function ace_extract_initial_markers(string $markedStem): array
{
$remainingStem = $markedStem;
$conjunction = '';
$futureMarker = '';
$vn = '';
/*
* Optional conjunction:
*
* فَ = fa
* وَ = wa
*/
if (mb_substr($remainingStem, 0, 2, 'UTF-8') === 'فَ') {
$conjunction = 'فَ';
$remainingStem = mb_substr(
$remainingStem,
2,
null,
'UTF-8'
);
} elseif (
mb_substr($remainingStem, 0, 2, 'UTF-8') === 'وَ'
) {
$conjunction = 'وَ';
$remainingStem = mb_substr(
$remainingStem,
2,
null,
'UTF-8'
);
}
/*
* Optional future marker:
*
* سَ = sa
*
* This is checked after removing the optional conjunction.
*/
if (mb_substr($remainingStem, 0, 2, 'UTF-8') === 'سَ') {
$futureMarker = 'سَ';
$remainingStem = mb_substr(
$remainingStem,
2,
null,
'UTF-8'
);
}
/*
* Imperfect-person marker.
*
* Preserve the marked form so its vowel remains available.
*/
$possibleVnMarkers = [
'يَ',
'يُ',
'تَ',
'تُ',
'أَ',
'أُ',
'نَ',
'نُ',
];
foreach ($possibleVnMarkers as $possibleVn) {
$possibleLength = mb_strlen(
$possibleVn,
'UTF-8'
);
if (
mb_substr(
$remainingStem,
0,
$possibleLength,
'UTF-8'
) !== $possibleVn
) {
continue;
}
$vn = $possibleVn;
$remainingStem = mb_substr(
$remainingStem,
$possibleLength,
null,
'UTF-8'
);
break;
}
return [
'conjunction' => $conjunction,
'future_marker' => $futureMarker,
'vn' => $vn,
/*
* Stem after conjunction, future marker, and imperfect marker.
* Use this value to identify vm and fs.
*/
'structural_stem' => $remainingStem,
];
}
function ace_first_vowel_mark(array $marks): string
{
$vowels = [
'َ',
'ُ',
'ِ',
'ْ',
'ً',
'ٌ',
'ٍ',
];
foreach ($marks as $mark) {
if (
is_string($mark)
&& in_array($mark, $vowels, true)
) {
return $mark;
}
}
return '';
}
function ace_collect_longest_prefix_matches(
string $token,
array $masterPrefixes
): array {
$longestLength = 0;
$matchedPrefix = '';
$matches = [];
foreach ($masterPrefixes as $entry) {
if (
!is_array($entry)
|| !isset($entry['prefix'])
|| !is_string($entry['prefix'])
) {
continue;
}
$prefix = $entry['prefix'];
/*
* Do not test the zero prefix during normal matching.
*/
if ($prefix === '') {
continue;
}
/*
* Prefix must occur at the beginning of the supplied
* analysis token.
*/
if (mb_strpos($token, $prefix, 0, 'UTF-8') !== 0) {
continue;
}
$length = ace_strlen($prefix);
/*
* A longer prefix replaces all shorter matches.
*/
if ($length > $longestLength) {
$longestLength = $length;
$matchedPrefix = $prefix;
$matches = [$entry];
continue;
}
/*
* Preserve grammatical alternatives that share the
* same longest prefix.
*/
if (
$length === $longestLength
&& $prefix === $matchedPrefix
) {
$matches[] = $entry;
}
}
return [
'matched_prefix' => $matchedPrefix,
'matched_prefix_length' => $longestLength,
'matched_prefix_entries' => $matches,
];
}
function ace_merge_prefix_match_information(
array $matchedEntries
): array {
$forms = [];
$families = [];
$tenses = [];
$voices = [];
$moods = [];
foreach ($matchedEntries as $entry) {
if (!is_array($entry)) {
continue;
}
$entryForms = $entry['forms'] ?? [];
if (!is_array($entryForms)) {
$entryForms = [$entryForms];
}
foreach ($entryForms as $form) {
if ($form === '' || $form === null) {
continue;
}
$forms[] = $form;
}
$family = (string) ($entry['family'] ?? '');
if ($family !== '') {
$families[] = $family;
}
$entryTenses = $entry['tense'] ?? [];
if (!is_array($entryTenses)) {
$entryTenses = [$entryTenses];
}
foreach ($entryTenses as $tense) {
$tense = (string) $tense;
if ($tense !== '') {
$tenses[] = $tense;
}
}
$entryVoices = $entry['voice'] ?? [];
if (!is_array($entryVoices)) {
$entryVoices = [$entryVoices];
}
foreach ($entryVoices as $voice) {
$voice = (string) $voice;
if ($voice !== '') {
$voices[] = $voice;
}
}
$entryMoods = $entry['moods'] ?? [];
if (!is_array($entryMoods)) {
$entryMoods = [$entryMoods];
}
foreach ($entryMoods as $mood) {
$mood = (string) $mood;
if ($mood !== '') {
$moods[] = $mood;
}
}
}
return [
'prefix_form_candidates' => array_values(
array_unique($forms, SORT_REGULAR)
),
'prefix_families' => array_values(
array_unique($families)
),
'prefix_tense_candidates' => array_values(
array_unique($tenses)
),
'prefix_voice_candidates' => array_values(
array_unique($voices)
),
'prefix_mood_candidates' => array_values(
array_unique($moods)
),
];
}
function ace_intersect_candidate_values(
array $first,
array $second
): array {
if ($first === []) {
return array_values(array_unique($second));
}
if ($second === []) {
return array_values(array_unique($first));
}
return array_values(
array_unique(
array_intersect($first, $second)
)
);
}
function ace_resolve_prefix_and_suffix_grammar(
array $prefixInformation,
array $suffixMoods
): array {
$prefixMoods = $prefixInformation[
'prefix_mood_candidates'
] ?? [];
$resolvedMoods = ace_intersect_candidate_values(
$prefixMoods,
$suffixMoods
);
/*
* If the intersection is empty, retain the suffix evidence.
* The suffix is usually more precise for mood.
*/
if ($resolvedMoods === [] && $suffixMoods !== []) {
$resolvedMoods = $suffixMoods;
}
return [
'form_candidates' => $prefixInformation[
'prefix_form_candidates'
] ?? [],
'tense_candidates' => $prefixInformation[
'prefix_tense_candidates'
] ?? [],
'voice_candidates' => $prefixInformation[
'prefix_voice_candidates'
] ?? [],
'mood_candidates' => $resolvedMoods,
];
}
/**
* ------------------------------------------------------------
* Match the longest prefix from the master prefix table.
* ------------------------------------------------------------
*/
function ace_match_longest_prefix(
string $token,
array $masterPrefixes
): array
{
$matchedPrefix = '';
$matchedPrefixEntry = null;
$matchedPrefixType = '';
$matchedPrefixLength = 0;
/*
* Longest prefixes should always be tested first.
*/
usort(
$masterPrefixes,
static function ($a, $b) {
return ($b['length'] ?? 0)
<=> ($a['length'] ?? 0);
}
);
foreach ($masterPrefixes as $entry) {
if (
!is_array($entry)
|| !isset($entry['prefix'])
|| !is_string($entry['prefix'])
) {
continue;
}
$prefix = $entry['prefix'];
if ($prefix === '') {
continue;
}
if (mb_substr($token, 0, mb_strlen($prefix)) === $prefix) {
$matchedPrefix = $prefix;
$matchedPrefixEntry = $entry;
$matchedPrefixLength = mb_strlen($prefix);
/*
* Optional classification.
*/
if (isset($entry['family'])) {
$matchedPrefixType = $entry['family'];
}
break;
}
}
return [
'matched_prefix' => $matchedPrefix,
'matched_prefix_entry' => $matchedPrefixEntry,
'matched_prefix_type' => $matchedPrefixType,
'matched_prefix_length' => $matchedPrefixLength,
];
}
/**
* Run the cumulative ACE reverse-engine pipeline for one token.
*
* The pipeline begins with the stable simple analyzer and then passes the
* cumulative analysis array through each available new-system stage.
*
* Missing optional stages are skipped so individual test pages can still run
* while the pipeline is being developed.
*
* @param string $token
* @param array $masterSuffixes
*
* @return array
*/
/**
* Run the cumulative ACE reverse-engine analysis pipeline.
*
* Every stage receives the current analysis array whenever possible.
* Array results are merged cumulatively so earlier fields are preserved.
*/