From e82f6b23d736c6fdd714f25594aa901f9851a01f Mon Sep 17 00:00:00 2001 From: John Lambert Date: Thu, 13 Aug 2026 02:13:26 -0400 Subject: [PATCH] Cap RealizationalRule synthesis application at once per word A Blockable RealizationalRule with no family (or no family member matching the word's current features) reapplied to its own output forever during synthesis: LinearRuleCascade retries a matching rule against its own result whenever that result differs from the input, and SynthesisRealizationalAffixProcessRule never checked an application-count cap the way SynthesisAffixProcessRule and SynthesisCompoundingRule already do. Since RealizationalRule has no multipleApplication attribute, cap it at one application per word. This hung ParseWord/AnalyzeWord confirmation for any root eligible for such a rule, including confirmation of the root's own bare-word parse. Co-Authored-By: Claude Opus 5 --- .../SynthesisRealizationalAffixProcessRule.cs | 18 +++++++++ .../MorpherTests.cs | 38 +++++++++++++++++++ 2 files changed, 56 insertions(+) diff --git a/src/SIL.Machine.Morphology.HermitCrab/MorphologicalRules/SynthesisRealizationalAffixProcessRule.cs b/src/SIL.Machine.Morphology.HermitCrab/MorphologicalRules/SynthesisRealizationalAffixProcessRule.cs index 1ea640909..061ae9065 100644 --- a/src/SIL.Machine.Morphology.HermitCrab/MorphologicalRules/SynthesisRealizationalAffixProcessRule.cs +++ b/src/SIL.Machine.Morphology.HermitCrab/MorphologicalRules/SynthesisRealizationalAffixProcessRule.cs @@ -43,6 +43,24 @@ public IEnumerable Apply(Word input) if (!_morpher.RuleSelector(_rule)) return Enumerable.Empty(); + // RealizationalRule has no multipleApplication attribute, so it applies at most once per + // word; otherwise a rule cascade that retries a matching rule against its own output would + // never terminate. + if (input.GetApplicationCount(_rule) >= 1) + { + if (_morpher.TraceManager.IsTracing) + { + _morpher.TraceManager.MorphologicalRuleNotApplied( + _rule, + -1, + input, + FailureReason.MaxApplicationCount, + 1 + ); + } + return Enumerable.Empty(); + } + if (!_rule.RealizationalFeatureStruct.Subsumes(input.RealizationalFeatureStruct)) return Enumerable.Empty(); diff --git a/tests/SIL.Machine.Morphology.HermitCrab.Tests/MorpherTests.cs b/tests/SIL.Machine.Morphology.HermitCrab.Tests/MorpherTests.cs index ab5a3f483..3c92898e3 100644 --- a/tests/SIL.Machine.Morphology.HermitCrab.Tests/MorpherTests.cs +++ b/tests/SIL.Machine.Morphology.HermitCrab.Tests/MorpherTests.cs @@ -141,6 +141,44 @@ public void AnalyzeWord_CanAnalyzeLinear_ReturnsCorrectAnalysis() ); } + [Test] + public void ParseWord_UnblockedRealizationalRuleMatchesOwnOutput_DoesNotHang() + { + var any = FeatureStruct.New().Symbol(HCFeatureSystem.Segment).Value; + + LexEntry entry = AddEntry( + "realtest", + FeatureStruct.New(Language.SyntacticFeatureSystem).Symbol("V").Value, + Morphophonemic, + "zag" + ); + entry.MprFeatures.Add(Latinate); + + var realRule = new RealizationalAffixProcessRule { Name = "real_rule", Gloss = "REAL" }; + realRule.Allomorphs.Add( + new AffixProcessAllomorph + { + Lhs = { Pattern.New("1").Annotation(any).OneOrMore.Value }, + Rhs = { new CopyFromInput("1"), new InsertSegments(Table3, "d") }, + } + ); + realRule.Allomorphs[0].RequiredMprFeatures.Add(Latinate); + Morphophonemic.MorphologicalRules.Add(realRule); + + SetRuleOrder(MorphologicalRuleOrder.Linear); + var morpher = new Morpher(TraceManager, Language); + + Word[]? output = null; + bool completed = Task.Run(() => output = morpher.ParseWord("zag").ToArray()).Wait(TimeSpan.FromSeconds(10)); + + Assert.That( + completed, + Is.True, + "ParseWord did not return: the realizational rule reapplied to its own output without bound." + ); + AssertMorphsEqual(output!, "realtest"); + } + [Test] public void AnalyzeWord_CannotAnalyze_ReturnsEmptyEnumerable() {