From 3734461304b7d2acc6c4e04b751649774553b0c9 Mon Sep 17 00:00:00 2001 From: John Lambert Date: Thu, 1 Oct 2026 15:33:00 -0400 Subject: [PATCH 1/2] Trace HermitCrab failure reasons and template slot outcomes Add optional detailed trace callbacks for analysis rejections, slot branches, and completed lexical lookup counts. Retain failure operands, blocking entries, and partial-parse causes in TraceManager without replaying linguistic checks. Preserve the ITraceManager member contract and verify legacy callbacks, parallel slot outcomes, interrupted lookups, and morphological analyses. Co-Authored-By: Claude Opus 5.5 --- .../ParseCommand.cs | 12 + .../AnalysisAffixTemplateRule.cs | 25 + .../IDetailedTraceManager.cs | 63 ++ .../ITraceManager.cs | 4 + .../Morpher.cs | 36 +- .../AnalysisAffixProcessRule.cs | 41 +- .../AnalysisCompoundingRule.cs | 54 +- .../AnalysisRealizationalAffixProcessRule.cs | 12 +- .../SynthesisAffixTemplateRule.cs | 16 + .../SynthesisStratumRule.cs | 5 +- .../Trace.cs | 23 + .../TraceManager.cs | 89 ++- .../TraceManagerExtensions.cs | 46 ++ src/SIL.Machine.Morphology.HermitCrab/Word.cs | 2 + .../TraceReasonTests.cs | 582 ++++++++++++++++++ 15 files changed, 977 insertions(+), 33 deletions(-) create mode 100644 src/SIL.Machine.Morphology.HermitCrab/IDetailedTraceManager.cs create mode 100644 src/SIL.Machine.Morphology.HermitCrab/TraceManagerExtensions.cs create mode 100644 tests/SIL.Machine.Morphology.HermitCrab.Tests/TraceReasonTests.cs diff --git a/src/SIL.Machine.Morphology.HermitCrab.Tool/ParseCommand.cs b/src/SIL.Machine.Morphology.HermitCrab.Tool/ParseCommand.cs index 86bfc0db1..df6ea1e81 100644 --- a/src/SIL.Machine.Morphology.HermitCrab.Tool/ParseCommand.cs +++ b/src/SIL.Machine.Morphology.HermitCrab.Tool/ParseCommand.cs @@ -140,7 +140,9 @@ private static bool IsAnalysis(TraceType type) case TraceType.StratumAnalysisOutput: case TraceType.TemplateAnalysisInput: case TraceType.TemplateAnalysisOutput: + case TraceType.TemplateSlotAnalysis: case TraceType.MorphologicalRuleAnalysis: + case TraceType.CompoundingRuleAnalysis: case TraceType.PhonologicalRuleAnalysis: return true; @@ -152,7 +154,9 @@ private static bool IsAnalysis(TraceType type) case TraceType.StratumSynthesisOutput: case TraceType.TemplateSynthesisInput: case TraceType.TemplateSynthesisOutput: + case TraceType.TemplateSlotSynthesis: case TraceType.MorphologicalRuleSynthesis: + case TraceType.CompoundingRuleSynthesis: case TraceType.PhonologicalRuleSynthesis: return false; } @@ -203,6 +207,14 @@ private static string GetTraceTypeString(TraceType type) return "Template Synthesis In"; case TraceType.TemplateSynthesisOutput: return "Template Synthesis Out"; + case TraceType.TemplateSlotAnalysis: + return "Template Slot Analysis"; + case TraceType.TemplateSlotSynthesis: + return "Template Slot Synthesis"; + case TraceType.CompoundingRuleAnalysis: + return "Compounding Rule Analysis"; + case TraceType.CompoundingRuleSynthesis: + return "Compounding Rule Synthesis"; case TraceType.MorphologicalRuleAnalysis: return "Morphological Rule Analysis"; case TraceType.MorphologicalRuleSynthesis: diff --git a/src/SIL.Machine.Morphology.HermitCrab/AnalysisAffixTemplateRule.cs b/src/SIL.Machine.Morphology.HermitCrab/AnalysisAffixTemplateRule.cs index 9044a8488..5d666ecd1 100644 --- a/src/SIL.Machine.Morphology.HermitCrab/AnalysisAffixTemplateRule.cs +++ b/src/SIL.Machine.Morphology.HermitCrab/AnalysisAffixTemplateRule.cs @@ -77,8 +77,18 @@ private void ApplySlots(Word inWord, int index, HashSet output) { for (int i = index; i >= 0; i--) { + bool producedOutput = false; foreach (Word outWord in _rules[i].Apply(inWord)) + { + producedOutput = true; + TraceSlot(inWord, outWord, i, TemplateSlotOutcome.RuleApplied); ApplySlots(outWord, i - 1, output); + } + + if (_template.Slots[i].Optional) + TraceSlot(inWord, null, i, TemplateSlotOutcome.OptionalSkipped); + else if (!producedOutput) + TraceSlot(inWord, null, i, TemplateSlotOutcome.RequiredUnfilled); if (!_template.Slots[i].Optional) { @@ -116,7 +126,16 @@ private void ParallelApplySlots(Word inWord, HashSet output) .Select(res => Tuple.Create(res, i - 1)) .ToArray(); if (workItems.Length > 0) + { + foreach (Tuple item in workItems) + TraceSlot(work.Item1, item.Item1, i, TemplateSlotOutcome.RuleApplied); to.PushRange(workItems); + } + + if (_template.Slots[i].Optional) + TraceSlot(work.Item1, null, i, TemplateSlotOutcome.OptionalSkipped); + else if (workItems.Length == 0) + TraceSlot(work.Item1, null, i, TemplateSlotOutcome.RequiredUnfilled); if (!_template.Slots[i].Optional) { @@ -152,5 +171,11 @@ private void ParallelApplySlots(Word inWord, HashSet output) output.UnionWith(outStack); } + + private void TraceSlot(Word input, Word output, int index, TemplateSlotOutcome outcome) + { + if (_morpher.TraceManager.IsTracing) + _morpher.TraceManager.TemplateSlotProcessed(_template, index, input, output, true, outcome); + } } } diff --git a/src/SIL.Machine.Morphology.HermitCrab/IDetailedTraceManager.cs b/src/SIL.Machine.Morphology.HermitCrab/IDetailedTraceManager.cs new file mode 100644 index 000000000..fe741565e --- /dev/null +++ b/src/SIL.Machine.Morphology.HermitCrab/IDetailedTraceManager.cs @@ -0,0 +1,63 @@ +namespace SIL.Machine.Morphology.HermitCrab +{ + public enum TemplateSlotOutcome + { + RuleApplied, + OptionalSkipped, + RequiredUnfilled, + } + + public enum PartialParseCause + { + RealizationalFeatureMismatch, + UnappliedMorphologicalRules, + NonFinalTemplateAppliedLast, + ApplicableTemplatesNotApplied, + } + + public class PartialParseFailure + { + public PartialParseFailure(PartialParseCause cause, IMorphologicalRule rule = null) + { + Cause = cause; + Rule = rule; + } + + public PartialParseCause Cause { get; } + + public IMorphologicalRule Rule { get; } + } + + /// + /// Optional tracing capability. ITraceManager implementations receive the existing callbacks without opting in. + /// + public interface IDetailedTraceManager : ITraceManager + { + void MorphologicalRuleNotUnapplied( + IMorphologicalRule rule, + int subruleIndex, + Word input, + FailureReason reason, + object failureObj + ); + + /// + /// Reports fully enumerated lookup results as yielded Word candidates, before surface validation. + /// Interrupted lookups do not receive this callback. Each input has at most one active lookup. + /// + void LexicalLookupCompleted(Stratum stratum, Word input, int candidateCount, bool guessed); + + /// + /// Reports a slot branch. OptionalSkipped can accompany RuleApplied for the same input. + /// RequiredUnfilled means the slot produced no output; no linguistic cause is implied. + /// + void TemplateSlotProcessed( + AffixTemplate template, + int slotIndex, + Word input, + Word output, + bool analysis, + TemplateSlotOutcome outcome + ); + } +} diff --git a/src/SIL.Machine.Morphology.HermitCrab/ITraceManager.cs b/src/SIL.Machine.Morphology.HermitCrab/ITraceManager.cs index f94dbdcb1..fe3d73f24 100644 --- a/src/SIL.Machine.Morphology.HermitCrab/ITraceManager.cs +++ b/src/SIL.Machine.Morphology.HermitCrab/ITraceManager.cs @@ -26,6 +26,10 @@ public enum FailureReason NonPartialRuleProhibitedAfterFinalTemplate, NonPartialRuleRequiredAfterNonFinalTemplate, MaxApplicationCount, + OutputSyntacticFeatureStruct, + RealizationalFeatureStruct, + MaxStemCount, + NonHeadLexicalLookup, } public interface ITraceManager diff --git a/src/SIL.Machine.Morphology.HermitCrab/Morpher.cs b/src/SIL.Machine.Morphology.HermitCrab/Morpher.cs index 1cddacd0a..5a2084b6a 100644 --- a/src/SIL.Machine.Morphology.HermitCrab/Morpher.cs +++ b/src/SIL.Machine.Morphology.HermitCrab/Morpher.cs @@ -444,6 +444,7 @@ internal IEnumerable SearchRootAllomorphs(Stratum stratum, Shape private IEnumerable LexicalLookup(Word input) { + int candidateCount = 0; if (_traceManager.IsTracing) _traceManager.LexicalLookup(input.Stratum, input); foreach ( @@ -464,9 +465,12 @@ LexEntry entry in SearchRootAllomorphs(input.Stratum, input.Shape) if (_traceManager.IsTracing) _traceManager.SynthesizeWord(_lang, newWord); newWord.Freeze(); + candidateCount++; yield return newWord; } } + if (_traceManager.IsTracing) + _traceManager.LexicalLookupCompleted(input.Stratum, input, candidateCount, false); } /// @@ -474,6 +478,7 @@ LexEntry entry in SearchRootAllomorphs(input.Stratum, input.Shape) /// private IEnumerable LexicalGuess(Word input) { + int candidateCount = 0; if (_traceManager.IsTracing) _traceManager.LexicalLookup(input.Stratum, input); CharacterDefinitionTable table = input.Stratum.CharacterDefinitionTable; @@ -540,10 +545,13 @@ private IEnumerable LexicalGuess(Word input) if (_traceManager.IsTracing) _traceManager.SynthesizeWord(_lang, newWord); newWord.Freeze(); + candidateCount++; yield return newWord; } } } + if (_traceManager.IsTracing) + _traceManager.LexicalLookupCompleted(input.Stratum, input, candidateCount, true); } /// @@ -657,13 +665,31 @@ private IEnumerable GetShapeStrings(IList nodes, CharacterDef private bool IsWordValid(Word word) { - if ( - !word.RealizationalFeatureStruct.IsUnifiable(word.SyntacticFeatureStruct) - || !word.IsAllMorphologicalRulesApplied - ) + if (!word.RealizationalFeatureStruct.IsUnifiable(word.SyntacticFeatureStruct)) + { + if (_traceManager.IsTracing) + _traceManager.Failed( + _lang, + word, + FailureReason.PartialParse, + null, + new PartialParseFailure(PartialParseCause.RealizationalFeatureMismatch) + ); + return false; + } + if (!word.IsAllMorphologicalRulesApplied) { if (_traceManager.IsTracing) - _traceManager.Failed(_lang, word, FailureReason.PartialParse, null, null); + _traceManager.Failed( + _lang, + word, + FailureReason.PartialParse, + null, + new PartialParseFailure( + PartialParseCause.UnappliedMorphologicalRules, + word.NextMorphologicalRule + ) + ); return false; } diff --git a/src/SIL.Machine.Morphology.HermitCrab/MorphologicalRules/AnalysisAffixProcessRule.cs b/src/SIL.Machine.Morphology.HermitCrab/MorphologicalRules/AnalysisAffixProcessRule.cs index 11980053c..37b104030 100644 --- a/src/SIL.Machine.Morphology.HermitCrab/MorphologicalRules/AnalysisAffixProcessRule.cs +++ b/src/SIL.Machine.Morphology.HermitCrab/MorphologicalRules/AnalysisAffixProcessRule.cs @@ -49,20 +49,38 @@ public IEnumerable Apply(Word input) if (!_morpher.RuleSelector(_rule)) return Enumerable.Empty(); - if ( - input.GetUnapplicationCount(_rule) >= _rule.MaxApplicationCount - || !_outputFS.IsUnifiable(input.SyntacticFeatureStruct) - ) + if (input.GetUnapplicationCount(_rule) >= _rule.MaxApplicationCount) { + if (_morpher.TraceManager.IsTracing) + _morpher.TraceManager.MorphologicalRuleNotUnapplied( + _rule, + -1, + input, + FailureReason.MaxApplicationCount, + _rule.MaxApplicationCount + ); + return Enumerable.Empty(); + } + if (!_outputFS.IsUnifiable(input.SyntacticFeatureStruct)) + { + if (_morpher.TraceManager.IsTracing) + _morpher.TraceManager.MorphologicalRuleNotUnapplied( + _rule, + -1, + input, + FailureReason.OutputSyntacticFeatureStruct, + _outputFS + ); return Enumerable.Empty(); } var output = new List(); for (int i = 0; i < _rules.Count; i++) { - bool unapplied = false; + bool matched = false; foreach (Word outWord in _rules[i].Apply(input).RemoveDuplicates()) { + matched = true; // the paths Out writes describe this rule's output, not the stem it applied to, so they are // stripped rather than carried backwards; what is left must unify with Required outWord.SyntacticFeatureStruct.Restrict(_rule.OutSyntacticFeatureStruct); @@ -75,6 +93,14 @@ out FeatureStruct syntacticFS ) ) { + if (_morpher.TraceManager.IsTracing) + _morpher.TraceManager.MorphologicalRuleNotUnapplied( + _rule, + i, + outWord, + FailureReason.RequiredSyntacticFeatureStruct, + _rule.RequiredSyntacticFeatureStruct + ); continue; } @@ -91,11 +117,10 @@ out FeatureStruct syntacticFS if (_morpher.TraceManager.IsTracing) _morpher.TraceManager.MorphologicalRuleUnapplied(_rule, i, input, outWord); output.Add(outWord); - unapplied = true; } - if (_morpher.TraceManager.IsTracing && !unapplied) - _morpher.TraceManager.MorphologicalRuleNotUnapplied(_rule, i, input); + if (_morpher.TraceManager.IsTracing && !matched) + _morpher.TraceManager.MorphologicalRuleNotUnapplied(_rule, i, input, FailureReason.Pattern, null); } return output; } diff --git a/src/SIL.Machine.Morphology.HermitCrab/MorphologicalRules/AnalysisCompoundingRule.cs b/src/SIL.Machine.Morphology.HermitCrab/MorphologicalRules/AnalysisCompoundingRule.cs index f77bf96e4..693b8ddd7 100644 --- a/src/SIL.Machine.Morphology.HermitCrab/MorphologicalRules/AnalysisCompoundingRule.cs +++ b/src/SIL.Machine.Morphology.HermitCrab/MorphologicalRules/AnalysisCompoundingRule.cs @@ -48,12 +48,19 @@ public IEnumerable Apply(Word input) if (!_morpher.RuleSelector(_rule)) return Enumerable.Empty(); - if ( - input.NonHeadCount + 1 >= _morpher.MaxStemCount - || input.GetUnapplicationCount(_rule) >= _rule.MaxApplicationCount - || !_outputFS.IsUnifiable(input.SyntacticFeatureStruct) - ) + if (input.NonHeadCount + 1 >= _morpher.MaxStemCount) { + TraceFailure(-1, input, FailureReason.MaxStemCount, _morpher.MaxStemCount); + return Enumerable.Empty(); + } + if (input.GetUnapplicationCount(_rule) >= _rule.MaxApplicationCount) + { + TraceFailure(-1, input, FailureReason.MaxApplicationCount, _rule.MaxApplicationCount); + return Enumerable.Empty(); + } + if (!_outputFS.IsUnifiable(input.SyntacticFeatureStruct)) + { + TraceFailure(-1, input, FailureReason.OutputSyntacticFeatureStruct, _outputFS); return Enumerable.Empty(); } @@ -61,14 +68,18 @@ public IEnumerable Apply(Word input) for (int i = 0; i < _rules.Count; i++) { var srOutput = new List(); + bool matched = false; foreach (Word outWord in _rules[i].Apply(input)) { + matched = true; + bool rootFound = false; // for computational complexity reasons, we ensure that the non-head is a root, otherwise we assume it is not // a valid analysis and throw it away foreach ( RootAllomorph allo in _morpher.SearchRootAllomorphs(_rule.Stratum, outWord.CurrentNonHead.Shape) ) { + rootFound = true; if ( !_rule.NonHeadRequiredSyntacticFeatureStruct.IsUnifiable( ((LexEntry)allo.Morpheme).SyntacticFeatureStruct, @@ -76,6 +87,17 @@ RootAllomorph allo in _morpher.SearchRootAllomorphs(_rule.Stratum, outWord.Curre ) ) { + if (_morpher.TraceManager.IsTracing) + { + Word tempInput = outWord.Clone(); + tempInput.CurrentNonHead.RootAllomorph = allo; + TraceFailure( + i, + tempInput, + FailureReason.NonHeadRequiredSyntacticFeatureStruct, + _rule.NonHeadRequiredSyntacticFeatureStruct + ); + } continue; } // make sure any productivity restrictions on the stem are in the set the member has @@ -92,7 +114,7 @@ RootAllomorph allo in _morpher.SearchRootAllomorphs(_rule.Stratum, outWord.Curre tempInput.CurrentTrace = input.CurrentTrace; _morpher.TraceManager.CompoundingRuleNotUnapplied( _rule, - -1, + i, tempInput, FailureReason.NonHeadProdRestrictMprFeatures, ((LexEntry)allo.Morpheme).MprFeatures @@ -128,9 +150,10 @@ RootAllomorph allo in _morpher.SearchRootAllomorphs(_rule.Stratum, outWord.Curre srOutput.Add(newWord); } } + if (!rootFound) + TraceFailure(i, outWord, FailureReason.NonHeadLexicalLookup, null); } - bool unapplied = false; foreach (Word outWord in srOutput) { // the paths Out writes describe this rule's output, not the stem; see AnalysisAffixProcessRule @@ -144,6 +167,12 @@ out FeatureStruct syntacticFS ) ) { + TraceFailure( + i, + outWord, + FailureReason.HeadRequiredSyntacticFeatureStruct, + _rule.HeadRequiredSyntacticFeatureStruct + ); continue; } outWord.SyntacticFeatureStruct = syntacticFS; @@ -155,14 +184,19 @@ out FeatureStruct syntacticFS if (_morpher.TraceManager.IsTracing) _morpher.TraceManager.MorphologicalRuleUnapplied(_rule, i, input, outWord); output.Add(outWord); - unapplied = true; } - if (_morpher.TraceManager.IsTracing && !unapplied) - _morpher.TraceManager.MorphologicalRuleNotUnapplied(_rule, i, input); + if (!matched) + TraceFailure(i, input, FailureReason.Pattern, null); } return output; } + + private void TraceFailure(int index, Word input, FailureReason reason, object failureObj) + { + if (_morpher.TraceManager.IsTracing) + _morpher.TraceManager.CompoundingRuleNotUnapplied(_rule, index, input, reason, failureObj); + } } } diff --git a/src/SIL.Machine.Morphology.HermitCrab/MorphologicalRules/AnalysisRealizationalAffixProcessRule.cs b/src/SIL.Machine.Morphology.HermitCrab/MorphologicalRules/AnalysisRealizationalAffixProcessRule.cs index 89e30fbe2..404542b72 100644 --- a/src/SIL.Machine.Morphology.HermitCrab/MorphologicalRules/AnalysisRealizationalAffixProcessRule.cs +++ b/src/SIL.Machine.Morphology.HermitCrab/MorphologicalRules/AnalysisRealizationalAffixProcessRule.cs @@ -44,7 +44,17 @@ public IEnumerable Apply(Word input) FeatureStruct realFS; if (!_rule.RealizationalFeatureStruct.Unify(input.RealizationalFeatureStruct, out realFS)) + { + if (_morpher.TraceManager.IsTracing) + _morpher.TraceManager.MorphologicalRuleNotUnapplied( + _rule, + -1, + input, + FailureReason.RealizationalFeatureStruct, + _rule.RealizationalFeatureStruct + ); return Enumerable.Empty(); + } var output = new List(); for (int i = 0; i < _rules.Count; i++) @@ -64,7 +74,7 @@ public IEnumerable Apply(Word input) } if (_morpher.TraceManager.IsTracing && !unapplied) - _morpher.TraceManager.MorphologicalRuleNotUnapplied(_rule, i, input); + _morpher.TraceManager.MorphologicalRuleNotUnapplied(_rule, i, input, FailureReason.Pattern, null); } return output; } diff --git a/src/SIL.Machine.Morphology.HermitCrab/SynthesisAffixTemplateRule.cs b/src/SIL.Machine.Morphology.HermitCrab/SynthesisAffixTemplateRule.cs index e8cf7ee17..0b7156800 100644 --- a/src/SIL.Machine.Morphology.HermitCrab/SynthesisAffixTemplateRule.cs +++ b/src/SIL.Machine.Morphology.HermitCrab/SynthesisAffixTemplateRule.cs @@ -38,8 +38,18 @@ private void ApplySlots(Word input, int index, HashSet output) { for (int i = index; i < _rules.Count; i++) { + bool producedOutput = false; foreach (Word outWord in _rules[i].Apply(input)) + { + producedOutput = true; + TraceSlot(input, outWord, i, TemplateSlotOutcome.RuleApplied); ApplySlots(outWord, i + 1, output); + } + + if (_template.Slots[i].Optional) + TraceSlot(input, null, i, TemplateSlotOutcome.OptionalSkipped); + else if (!producedOutput) + TraceSlot(input, null, i, TemplateSlotOutcome.RequiredUnfilled); if (!_template.Slots[i].Optional) { @@ -53,5 +63,11 @@ private void ApplySlots(Word input, int index, HashSet output) _morpher.TraceManager.EndApplyTemplate(_template, input, true); output.Add(input); } + + private void TraceSlot(Word input, Word output, int index, TemplateSlotOutcome outcome) + { + if (_morpher.TraceManager.IsTracing) + _morpher.TraceManager.TemplateSlotProcessed(_template, index, input, output, false, outcome); + } } } diff --git a/src/SIL.Machine.Morphology.HermitCrab/SynthesisStratumRule.cs b/src/SIL.Machine.Morphology.HermitCrab/SynthesisStratumRule.cs index a15a6de5b..fc9a73491 100644 --- a/src/SIL.Machine.Morphology.HermitCrab/SynthesisStratumRule.cs +++ b/src/SIL.Machine.Morphology.HermitCrab/SynthesisStratumRule.cs @@ -71,7 +71,10 @@ public IEnumerable Apply(Word input) mruleOutWord, FailureReason.PartialParse, null, - null + new PartialParseFailure( + PartialParseCause.UnappliedMorphologicalRules, + mruleOutWord.NextMorphologicalRule + ) ); } } diff --git a/src/SIL.Machine.Morphology.HermitCrab/Trace.cs b/src/SIL.Machine.Morphology.HermitCrab/Trace.cs index 10abc27ab..26eab5ccb 100644 --- a/src/SIL.Machine.Morphology.HermitCrab/Trace.cs +++ b/src/SIL.Machine.Morphology.HermitCrab/Trace.cs @@ -113,6 +113,8 @@ public enum TraceType /// Parse failed trace /// Failed, + TemplateSlotAnalysis, + TemplateSlotSynthesis, } /// @@ -152,5 +154,26 @@ public IHCRule Source public Word Output { get; internal set; } public FailureReason FailureReason { get; internal set; } + + public object FailureObject { get; internal set; } + + public Allomorph Allomorph { get; internal set; } + + public LexEntry BlockingEntry { get; internal set; } + + public PartialParseCause? PartialParseCause { get; internal set; } + + public int? SlotIndex { get; internal set; } + + public TemplateSlotOutcome? SlotOutcome { get; internal set; } + + public IMorphologicalRule SlotRule { get; internal set; } + + /// + /// Null until the lookup is fully enumerated; counts yielded Word candidates, before surface validation. + /// + public int? LexicalCandidateCount { get; internal set; } + + public bool? IsLexicalGuess { get; internal set; } } } diff --git a/src/SIL.Machine.Morphology.HermitCrab/TraceManager.cs b/src/SIL.Machine.Morphology.HermitCrab/TraceManager.cs index 3fa99c9b9..72536a8a8 100644 --- a/src/SIL.Machine.Morphology.HermitCrab/TraceManager.cs +++ b/src/SIL.Machine.Morphology.HermitCrab/TraceManager.cs @@ -1,7 +1,9 @@ namespace SIL.Machine.Morphology.HermitCrab { - public class TraceManager : ITraceManager + public class TraceManager : IDetailedTraceManager { + private readonly System.Runtime.CompilerServices.ConditionalWeakTable _lexicalLookups = + new System.Runtime.CompilerServices.ConditionalWeakTable(); public bool IsTracing { get; set; } public object GenerateWords(Language lang) @@ -82,9 +84,26 @@ public void MorphologicalRuleUnapplied(IMorphologicalRule rule, int subruleIndex } public void MorphologicalRuleNotUnapplied(IMorphologicalRule rule, int subruleIndex, Word input) + { + MorphologicalRuleNotUnapplied(rule, subruleIndex, input, FailureReason.None, null); + } + + public void MorphologicalRuleNotUnapplied( + IMorphologicalRule rule, + int subruleIndex, + Word input, + FailureReason reason, + object failureObj + ) { ((Trace)input.CurrentTrace).Children.Add( - new Trace(TraceType.MorphologicalRuleAnalysis, rule) { SubruleIndex = subruleIndex, Input = input } + new Trace(TraceType.MorphologicalRuleAnalysis, rule) + { + SubruleIndex = subruleIndex, + Input = input, + FailureReason = reason, + FailureObject = failureObj, + } ); } @@ -102,6 +121,7 @@ object failureObj SubruleIndex = subruleIndex, Input = input, FailureReason = reason, + FailureObject = failureObj, } ); } @@ -120,22 +140,37 @@ object failureObj SubruleIndex = subruleIndex, Input = input, FailureReason = reason, + FailureObject = failureObj, } ); } public void LexicalLookup(Stratum stratum, Word input) { - ((Trace)input.CurrentTrace).Children.Add( - new Trace(TraceType.LexicalLookup, stratum) { Input = input.Clone() } - ); + var trace = new Trace(TraceType.LexicalLookup, stratum) { Input = input.Clone() }; + ((Trace)input.CurrentTrace).Children.Add(trace); + _lexicalLookups.Remove(input); + _lexicalLookups.Add(input, trace); + } + + public void LexicalLookupCompleted(Stratum stratum, Word input, int candidateCount, bool guessed) + { + if (_lexicalLookups.TryGetValue(input, out Trace trace)) + { + trace.LexicalCandidateCount = candidateCount; + trace.IsLexicalGuess = guessed; + _lexicalLookups.Remove(input); + } } public void SynthesizeWord(Language lang, Word input) { var trace = new Trace(TraceType.WordSynthesis, lang) { Input = input.Clone() }; var curTrace = (Trace)input.CurrentTrace; - curTrace.Children.Last.Children.Add(trace); + if (input.Source != null && _lexicalLookups.TryGetValue(input.Source, out Trace lookup)) + lookup.Children.Add(trace); + else + curTrace.Children.Last.Children.Add(trace); input.CurrentTrace = trace; } @@ -153,6 +188,7 @@ public void NonFinalTemplateAppliedLast(Stratum stratum, Word word) { Output = word, FailureReason = FailureReason.PartialParse, + PartialParseCause = PartialParseCause.NonFinalTemplateAppliedLast, } ); } @@ -164,6 +200,7 @@ public void ApplicableTemplatesNotApplied(Stratum stratum, Word word) { Output = word, FailureReason = FailureReason.PartialParse, + PartialParseCause = PartialParseCause.ApplicableTemplatesNotApplied, } ); } @@ -201,6 +238,7 @@ object failureObj SubruleIndex = subruleIndex, Input = input.Clone(), FailureReason = reason, + FailureObject = failureObj, } ); } @@ -245,13 +283,20 @@ object failureObj SubruleIndex = subruleIndex, Input = input, FailureReason = reason, + FailureObject = failureObj, } ); } public void Blocked(IHCRule rule, Word output) { - ((Trace)output.CurrentTrace).Children.Add(new Trace(TraceType.Blocked, rule) { Output = output }); + ((Trace)output.CurrentTrace).Children.Add( + new Trace(TraceType.Blocked, rule) + { + Output = output, + BlockingEntry = output.RootAllomorph?.Morpheme as LexEntry, + } + ); } public void Successful(Language lang, Word word) @@ -262,7 +307,35 @@ public void Successful(Language lang, Word word) public void Failed(Language lang, Word word, FailureReason reason, Allomorph allomorph, object failureObj) { ((Trace)word.CurrentTrace).Children.Add( - new Trace(TraceType.Failed, lang) { Output = word, FailureReason = reason } + new Trace(TraceType.Failed, lang) + { + Output = word, + FailureReason = reason, + FailureObject = failureObj, + Allomorph = allomorph, + PartialParseCause = (failureObj as PartialParseFailure)?.Cause, + } + ); + } + + public void TemplateSlotProcessed( + AffixTemplate template, + int slotIndex, + Word input, + Word output, + bool analysis, + TemplateSlotOutcome outcome + ) + { + ((Trace)input.CurrentTrace).Children.Add( + new Trace(analysis ? TraceType.TemplateSlotAnalysis : TraceType.TemplateSlotSynthesis, template) + { + Input = input, + Output = output, + SlotIndex = slotIndex, + SlotOutcome = outcome, + SlotRule = output == null ? null : ((Trace)output.CurrentTrace).Source as IMorphologicalRule, + } ); } } diff --git a/src/SIL.Machine.Morphology.HermitCrab/TraceManagerExtensions.cs b/src/SIL.Machine.Morphology.HermitCrab/TraceManagerExtensions.cs new file mode 100644 index 000000000..0b48ef1ae --- /dev/null +++ b/src/SIL.Machine.Morphology.HermitCrab/TraceManagerExtensions.cs @@ -0,0 +1,46 @@ +namespace SIL.Machine.Morphology.HermitCrab +{ + internal static class TraceManagerExtensions + { + public static void MorphologicalRuleNotUnapplied( + this ITraceManager traceManager, + IMorphologicalRule rule, + int subruleIndex, + Word input, + FailureReason reason, + object failureObj + ) + { + if (traceManager is IDetailedTraceManager detailed) + detailed.MorphologicalRuleNotUnapplied(rule, subruleIndex, input, reason, failureObj); + else + traceManager.MorphologicalRuleNotUnapplied(rule, subruleIndex, input); + } + + public static void LexicalLookupCompleted( + this ITraceManager traceManager, + Stratum stratum, + Word input, + int candidateCount, + bool guessed + ) + { + if (traceManager is IDetailedTraceManager detailed) + detailed.LexicalLookupCompleted(stratum, input, candidateCount, guessed); + } + + public static void TemplateSlotProcessed( + this ITraceManager traceManager, + AffixTemplate template, + int slotIndex, + Word input, + Word output, + bool analysis, + TemplateSlotOutcome outcome + ) + { + if (traceManager is IDetailedTraceManager detailed) + detailed.TemplateSlotProcessed(template, slotIndex, input, output, analysis, outcome); + } + } +} diff --git a/src/SIL.Machine.Morphology.HermitCrab/Word.cs b/src/SIL.Machine.Morphology.HermitCrab/Word.cs index 45d2642e6..8c9a2707e 100644 --- a/src/SIL.Machine.Morphology.HermitCrab/Word.cs +++ b/src/SIL.Machine.Morphology.HermitCrab/Word.cs @@ -249,6 +249,8 @@ public IEnumerable MorphologicalRules internal int MorphologicalRuleApplicationCount => _mruleAppCount; + internal IMorphologicalRule NextMorphologicalRule => _mruleAppIndex < 0 ? null : _mruleApps[_mruleAppIndex]; + internal bool IsAllMorphologicalRulesApplied { get { return _mruleAppIndex == -1; } diff --git a/tests/SIL.Machine.Morphology.HermitCrab.Tests/TraceReasonTests.cs b/tests/SIL.Machine.Morphology.HermitCrab.Tests/TraceReasonTests.cs new file mode 100644 index 000000000..83ef471ed --- /dev/null +++ b/tests/SIL.Machine.Morphology.HermitCrab.Tests/TraceReasonTests.cs @@ -0,0 +1,582 @@ +using NUnit.Framework; +using SIL.Machine.Annotations; +using SIL.Machine.FeatureModel; +using SIL.Machine.Matching; +using SIL.Machine.Morphology.HermitCrab.MorphologicalRules; +using SIL.Machine.Morphology.HermitCrab.PhonologicalRules; +using SIL.Machine.Rules; + +namespace SIL.Machine.Morphology.HermitCrab; + +[TestFixture] +public class TraceReasonTests +{ + private Language _language = default!; + private Stratum _stratum = default!; + private CharacterDefinitionTable _table = default!; + private TraceManager _traceManager = default!; + private static readonly FeatureStruct Any = FeatureStruct.New().Symbol(HCFeatureSystem.Segment).Value; + + [SetUp] + public void SetUp() + { + var phonology = new FeatureSystem + { + new SymbolicFeature("letter", "abds".Select(c => new FeatureSymbol(c.ToString()))), + }; + _table = new CharacterDefinitionTable(); + foreach (char c in "abds") + _table.AddSegment(c.ToString(), FeatureStruct.New(phonology).Symbol(c.ToString()).Value.Clone()); + _table.AddBoundary("+"); + _stratum = new Stratum(_table) { Name = "lexical" }; + _language = new Language { PhonologicalFeatureSystem = phonology, Strata = { _stratum } }; + _language.SyntacticFeatureSystem.AddPartsOfSpeech(new FeatureSymbol("N"), new FeatureSymbol("V")); + _traceManager = new TraceManager { IsTracing = true }; + } + + [TestCase("a", "Pattern")] + [TestCase("as", "MaxApplicationCount")] + public void AffixAnalysis_ReportsFailedGate(string form, string expectedReason) + { + AffixProcessRule rule = Suffix(); + Word input = AnalysisInput(form); + if (expectedReason == "MaxApplicationCount") + input.MorphologicalRuleUnapplied(rule); + input.Freeze(); + Assert.That(rule.CompileAnalysisRule(Morpher()).Apply(input), Is.Empty); + Trace failure = Events(input).Single(t => t.Type == TraceType.MorphologicalRuleAnalysis); + Assert.That(failure.FailureReason.ToString(), Is.EqualTo(expectedReason)); + Assert.That(failure.SubruleIndex, Is.EqualTo(expectedReason == "Pattern" ? 0 : -1)); + } + + [Test] + public void AffixAnalysis_ReportsOutputFeatureConflict() + { + AffixProcessRule rule = Suffix(); + rule.OutSyntacticFeatureStruct = Pos("V"); + Word input = AnalysisInput("as"); + input.SyntacticFeatureStruct = Pos("N"); + input.Freeze(); + Assert.That(rule.CompileAnalysisRule(Morpher()).Apply(input), Is.Empty); + Trace failure = Events(input).Single(t => t.Type == TraceType.MorphologicalRuleAnalysis); + Assert.That(failure.FailureReason.ToString(), Is.EqualTo("OutputSyntacticFeatureStruct")); + Assert.That(failure.FailureObject, Is.InstanceOf()); + } + + [Test] + public void RealizationalAnalysis_ReportsFeatureConflict() + { + var rule = new RealizationalAffixProcessRule { RealizationalFeatureStruct = Pos("V") }; + rule.Allomorphs.Add(Suffix().Allomorphs[0]); + Word input = AnalysisInput("as"); + input.RealizationalFeatureStruct = Pos("N"); + input.Freeze(); + Assert.That(rule.CompileAnalysisRule(Morpher()).Apply(input), Is.Empty); + Trace failure = Events(input).Single(t => t.Type == TraceType.MorphologicalRuleAnalysis); + Assert.That(failure.FailureReason.ToString(), Is.EqualTo("RealizationalFeatureStruct")); + Assert.That(failure.FailureObject, Is.SameAs(rule.RealizationalFeatureStruct)); + } + + [TestCase(1, true, "a", "OptionalSkipped")] + [TestCase(2, true, "a", "OptionalSkipped")] + [TestCase(1, false, "a", "RequiredUnfilled")] + [TestCase(2, false, "a", "RequiredUnfilled")] + [TestCase(1, false, "as", "RuleApplied")] + [TestCase(2, false, "as", "RuleApplied")] + public void TemplateAnalysis_ReportsSlotOutcome(int parallelism, bool optional, string form, string outcome) + { + var template = new AffixTemplate { Slots = { new AffixTemplateSlot(Suffix()) { Optional = optional } } }; + Word input = AnalysisInput(form); + input.Freeze(); + Word[] output = template.CompileAnalysisRule(Morpher(parallelism)).Apply(input).ToArray(); + Assert.That(output.Length, Is.EqualTo(outcome == "RequiredUnfilled" ? 0 : 1)); + Trace slot = Events(input).Single(t => t.Type.ToString() == "TemplateSlotAnalysis"); + Assert.That(slot.SlotIndex, Is.EqualTo(0)); + Assert.That(slot.SlotOutcome, Is.EqualTo(Enum.Parse(outcome))); + Assert.That(slot.Output is null, Is.EqualTo(outcome != "RuleApplied")); + } + + [TestCase(true, "OptionalSkipped")] + [TestCase(false, "RequiredUnfilled")] + public void TemplateSynthesis_ReportsEmptySlot(bool optional, string outcome) + { + var template = new AffixTemplate { Slots = { new AffixTemplateSlot(Suffix()) { Optional = optional } } }; + Word input = new Word(Entry("a").PrimaryAllomorph, new FeatureStruct()); + _traceManager.AnalyzeWord(_language, input); + input.Freeze(); + Word[] output = template.CompileSynthesisRule(Morpher()).Apply(input).ToArray(); + Assert.That(output.Length, Is.EqualTo(optional ? 1 : 0)); + Trace slot = Events(input).Single(t => t.Type.ToString() == "TemplateSlotSynthesis"); + Assert.That(slot.SlotOutcome, Is.EqualTo(Enum.Parse(outcome))); + } + + [TestCase("a", 1)] + [TestCase("b", 0)] + public void LexicalLookup_ReportsCompletedCandidateCount(string form, int count) + { + Entry("a"); + Word[] output = Morpher().ParseWord(form, out object trace).ToArray(); + Assert.That(output.Length, Is.EqualTo(count)); + Trace lookup = Descendants((Trace)trace).Single(t => t.Type == TraceType.LexicalLookup); + Assert.That(lookup.LexicalCandidateCount, Is.EqualTo(count)); + Assert.That(lookup.IsLexicalGuess, Is.EqualTo(false)); + Assert.That(lookup.Children.Count(t => t.Type == TraceType.WordSynthesis), Is.EqualTo(count)); + } + + [Test] + public void Blocking_IdentifiesReplacementFamilyEntry() + { + LexEntry stem = Entry("a"); + LexEntry blocker = Entry("b"); + var family = new LexFamily { Entries = { stem, blocker } }; + _language.Families.Add(family); + AffixProcessRule suffix = Suffix(); + suffix.Blockable = true; + Word input = new Word(stem.PrimaryAllomorph, new FeatureStruct()); + input.MorphologicalRuleUnapplied(suffix); + _traceManager.AnalyzeWord(_language, input); + input.Freeze(); + Word result = suffix.CompileSynthesisRule(Morpher()).Apply(input).Single(); + Assert.That(result.RootAllomorph.Morpheme, Is.SameAs(blocker)); + Trace blocked = Events(input).Single(t => t.Type == TraceType.Blocked); + Assert.That(blocked.BlockingEntry, Is.SameAs(blocker)); + } + + [Test] + public void EnvironmentFailure_RetainsAllomorphAndFailedAlternatives() + { + RootAllomorph allomorph = Entry("a").PrimaryAllomorph; + var first = new AllomorphEnvironment( + ConstraintType.Require, + null, + Pattern.New().Annotation(_table["b"].FeatureStruct).Value + ) + { + Name = "before b", + }; + var second = new AllomorphEnvironment( + ConstraintType.Require, + Pattern.New().Annotation(_table["d"].FeatureStruct).Value, + null + ) + { + Name = "after d", + }; + allomorph.Environments.Add(first); + allomorph.Environments.Add(second); + Assert.That(Morpher().ParseWord("a", out object trace), Is.Empty); + Trace failure = Descendants((Trace)trace).Single(t => t.FailureReason == FailureReason.Environments); + Assert.That(failure.Allomorph, Is.SameAs(allomorph)); + Assert.That(failure.FailureObject, Is.EquivalentTo(new[] { first, second })); + } + + [Test] + public void CompoundingAnalysis_ReportsPatternRejection() + { + CompoundingRule rule = Compound(); + Word input = AnalysisInput("a"); + input.Freeze(); + Assert.That(rule.CompileAnalysisRule(Morpher()).Apply(input), Is.Empty); + Trace failure = Events(input).Single(t => t.Type == TraceType.CompoundingRuleAnalysis); + Assert.That(failure.FailureReason, Is.EqualTo(FailureReason.Pattern)); + Assert.That(failure.SubruleIndex, Is.Zero); + } + + [Test] + public void PhonologicalAnalysis_NoUnapplicationKeepsReasonUnknown() + { + var rule = new RewriteRule + { + Lhs = Pattern.New().Annotation(_table["b"].FeatureStruct).Value, + Subrules = + { + new RewriteSubrule { Rhs = Pattern.New().Annotation(_table["d"].FeatureStruct).Value }, + }, + }; + Word input = AnalysisInput("a"); + Assert.That(rule.CompileAnalysisRule(Morpher()).Apply(input), Is.Empty); + Trace failure = Events(input).Single(t => t.Type == TraceType.PhonologicalRuleAnalysis); + Assert.Multiple(() => + { + Assert.That(failure.Source, Is.SameAs(rule)); + Assert.That(failure.SubruleIndex, Is.Zero); + Assert.That(failure.Input.Shape.ToString(_table, false), Is.EqualTo("a")); + Assert.That(failure.Output, Is.Null); + Assert.That(failure.FailureReason, Is.EqualTo(FailureReason.None)); + }); + } + + [TestCase(false, "UnappliedMorphologicalRules")] + [TestCase(true, "RealizationalFeatureMismatch")] + public void PartialParse_ReportsCompletionGate(bool realizational, string cause) + { + Entry("a"); + IMorphologicalRule rule; + if (realizational) + { + var affix = new RealizationalAffixProcessRule { RealizationalFeatureStruct = Pos("V") }; + affix.Allomorphs.Add(Suffix().Allomorphs[0]); + rule = affix; + } + else + { + rule = Suffix(); + } + _stratum.MorphologicalRules.Add(rule); + bool synthesis = false; + Morpher morpher = Morpher(); + morpher.LexEntrySelector = _ => + { + synthesis = true; + return true; + }; + morpher.RuleSelector = selected => !synthesis; + Assert.That(morpher.ParseWord("as", out object trace), Is.Empty); + Trace failure = Descendants((Trace)trace).Single(t => t.FailureReason == FailureReason.PartialParse); + Assert.That(failure.PartialParseCause, Is.EqualTo(Enum.Parse(cause))); + } + + [TestCase(false, "ApplicableTemplatesNotApplied")] + [TestCase(true, "NonFinalTemplateAppliedLast")] + public void PartialParse_ReportsTemplateCompletionGate(bool nonFinal, string cause) + { + Entry("a"); + var template = new AffixTemplate + { + IsFinal = !nonFinal, + Slots = { new AffixTemplateSlot(Suffix()) { Optional = nonFinal } }, + }; + _stratum.AffixTemplates.Add(template); + Assert.That(Morpher().ParseWord("a", out object trace), Is.Empty); + Trace failure = Descendants((Trace)trace).Single(t => t.FailureReason == FailureReason.PartialParse); + Assert.That(failure.PartialParseCause, Is.EqualTo(Enum.Parse(cause))); + } + + [TestCase(1)] + [TestCase(2)] + public void OptionalSlot_ReportsAppliedAndSkippedBranches(int parallelism) + { + var suffix = Suffix(); + var template = new AffixTemplate { Slots = { new AffixTemplateSlot(suffix) { Optional = true } } }; + Word input = AnalysisInput("as"); + input.Freeze(); + Word[] output = template.CompileAnalysisRule(Morpher(parallelism)).Apply(input).ToArray(); + Assert.That(output.Select(w => w.Shape.ToString(_table, false)), Is.EquivalentTo(new[] { "a", "as" })); + Trace[] slots = Events(input).Where(t => t.Type == TraceType.TemplateSlotAnalysis).ToArray(); + Assert.That( + slots.Select(t => t.SlotOutcome), + Is.EquivalentTo(new[] { TemplateSlotOutcome.RuleApplied, TemplateSlotOutcome.OptionalSkipped }) + ); + Assert.That(slots.Single(t => t.SlotOutcome == TemplateSlotOutcome.RuleApplied).SlotRule, Is.SameAs(suffix)); + } + + [Test] + public void LexicalLookup_InterleavedInputsKeepTheirOwnSynthesisAndCompletion() + { + Word first = AnalysisInput("a"); + Word second = new Word(_stratum, _table.Segment("b")) { CurrentTrace = first.CurrentTrace }; + _traceManager.LexicalLookup(_stratum, first); + _traceManager.LexicalLookup(_stratum, second); + Word synthesis = first.Clone(); + _traceManager.SynthesizeWord(_language, synthesis); + _traceManager.LexicalLookupCompleted(_stratum, second, 0, false); + _traceManager.LexicalLookupCompleted(_stratum, first, 1, false); + Trace[] lookups = Events(first).Where(t => t.Type == TraceType.LexicalLookup).ToArray(); + Assert.Multiple(() => + { + Assert.That(lookups.Select(t => t.LexicalCandidateCount), Is.EqualTo(new[] { 1, 0 })); + Assert.That(lookups[0].Children.Single().Type, Is.EqualTo(TraceType.WordSynthesis)); + Assert.That(lookups[1].Children, Is.Empty); + }); + } + + [Test] + public void LegacyTraceManager_ReceivesExistingCallbacks() + { + ITraceManager legacy = System.Reflection.DispatchProxy.Create(); + var proxy = (LegacyTraceProxy)legacy; + proxy.Target = _traceManager; + Word input = AnalysisInput("a"); + var morpher = new Morpher(legacy, _language, 1); + Assert.That(Suffix().CompileAnalysisRule(morpher).Apply(input), Is.Empty); + Assert.That( + proxy.Calls.Count(name => name == nameof(ITraceManager.MorphologicalRuleNotUnapplied)), + Is.EqualTo(1) + ); + var template = new AffixTemplate { Slots = { new AffixTemplateSlot(Suffix()) { Optional = true } } }; + Assert.That(template.CompileAnalysisRule(morpher).Apply(input).Count(), Is.EqualTo(1)); + Assert.That(Events(input).Any(t => t.Type == TraceType.TemplateSlotAnalysis), Is.False); + Assert.That(morpher.ParseWord("b", out object trace), Is.Empty); + Assert.That( + Descendants((Trace)trace).Single(t => t.Type == TraceType.LexicalLookup).LexicalCandidateCount, + Is.Null + ); + } + + public class LegacyTraceProxy : System.Reflection.DispatchProxy + { + public ITraceManager Target { get; set; } = default!; + public List Calls { get; } = new List(); + + protected override object? Invoke(System.Reflection.MethodInfo? targetMethod, object?[]? args) + { + Calls.Add(targetMethod!.Name); + return targetMethod.Invoke(Target, args); + } + } + + [TestCase("a", 1)] + [TestCase("b", 0)] + public void LexicalGuess_ReportsSeparateCompletedLookup(string form, int count) + { + _table.AddNaturalClass(new NaturalClass(_table["a"].FeatureStruct) { Name = "A" }); + _stratum.Entries.Add( + new LexEntry + { + Id = "pattern", + SyntacticFeatureStruct = Pos("N"), + Allomorphs = { new RootAllomorph(new Segments(_table, "[A]*", true)) }, + } + ); + Assert.That(Morpher().ParseWord(form, out object trace, true).Count(), Is.EqualTo(count)); + Trace[] lookups = Descendants((Trace)trace).Where(t => t.Type == TraceType.LexicalLookup).ToArray(); + Assert.That(lookups.Select(t => t.IsLexicalGuess), Is.EqualTo(new[] { false, true })); + Assert.That(lookups.Select(t => t.LexicalCandidateCount), Is.EqualTo(new[] { 0, count })); + Assert.That(lookups[1].Children.Count(t => t.Type == TraceType.WordSynthesis), Is.EqualTo(count)); + } + + [Test] + public void InterruptedLookup_DoesNotReportCompletedCount() + { + Entry("a").Allomorphs.Add(new RootAllomorph(new Segments(_table, "b"))); + Morpher morpher = Morpher(); + morpher.MaxAlternatives = 1; + object trace = null!; + Assert.Throws(() => morpher.ParseWord("a", out trace).ToArray()); + Assert.That( + Descendants((Trace)trace).Single(t => t.Type == TraceType.LexicalLookup).LexicalCandidateCount, + Is.Null + ); + } + + [TestCase("MaxStemCount")] + [TestCase("MaxApplicationCount")] + [TestCase("OutputSyntacticFeatureStruct")] + public void CompoundingAnalysis_ReportsRuleGate(string reason) + { + CompoundingRule rule = Compound(); + Word input = AnalysisInput("ab"); + Morpher morpher = Morpher(); + switch (reason) + { + case "MaxStemCount": + morpher.MaxStemCount = 1; + break; + case "MaxApplicationCount": + input.MorphologicalRuleUnapplied(rule); + break; + default: + input.SyntacticFeatureStruct = Pos("N"); + rule.OutSyntacticFeatureStruct = Pos("V"); + break; + } + input.Freeze(); + Assert.That(rule.CompileAnalysisRule(morpher).Apply(input), Is.Empty); + Trace failure = Events(input).Single(t => t.Type == TraceType.CompoundingRuleAnalysis); + Assert.That(failure.FailureReason, Is.EqualTo(Enum.Parse(reason))); + Assert.That(failure.SubruleIndex, Is.EqualTo(-1)); + Assert.That(failure.FailureObject, Is.Not.Null); + } + + [TestCase("NonHeadLexicalLookup")] + [TestCase("NonHeadRequiredSyntacticFeatureStruct")] + [TestCase("NonHeadProdRestrictMprFeatures")] + public void CompoundingAnalysis_ReportsNonHeadRejection(string reason) + { + CompoundingRule rule = Compound(); + LexEntry? nonHead = reason == "NonHeadLexicalLookup" ? null : Entry("b"); + if (reason == "NonHeadRequiredSyntacticFeatureStruct") + rule.NonHeadRequiredSyntacticFeatureStruct = Pos("V"); + else if (reason == "NonHeadProdRestrictMprFeatures") + rule.NonHeadProdRestrictionsMprFeatures.Add(new MprFeature { Name = "required" }); + Word input = AnalysisInput("ab"); + input.Freeze(); + Assert.That(rule.CompileAnalysisRule(Morpher()).Apply(input), Is.Empty); + Trace failure = Events(input).Single(t => t.Type == TraceType.CompoundingRuleAnalysis); + Assert.That(failure.FailureReason, Is.EqualTo(Enum.Parse(reason))); + Assert.That(failure.SubruleIndex, Is.Zero); + if (nonHead != null) + Assert.That(failure.Input.CurrentNonHead.RootAllomorph.Morpheme, Is.SameAs(nonHead)); + if (reason == "NonHeadProdRestrictMprFeatures") + Assert.That(failure.FailureObject, Is.SameAs(nonHead!.MprFeatures)); + } + + [Test] + public void CompoundingAnalysis_SuccessRetainsExistingTraceType() + { + CompoundingRule rule = Compound(); + Entry("b"); + Word input = AnalysisInput("ab"); + input.Freeze(); + Word output = rule.CompileAnalysisRule(Morpher()).Apply(input).Single(); + Assert.That(output.Shape.ToString(_table, false), Is.EqualTo("a")); + Trace applied = Events(input).Single(); + Assert.That(applied.Type, Is.EqualTo(TraceType.MorphologicalRuleAnalysis)); + Assert.That(applied.Source, Is.SameAs(rule)); + Assert.That(applied.Output, Is.SameAs(output)); + } + + [TestCase(1, false)] + [TestCase(1, true)] + [TestCase(2, false)] + [TestCase(2, true)] + public void TraceDetails_PreserveMorphologicalAnalyses(int parallelism, bool tracing) + { + Entry("a"); + AffixProcessRule suffix = Suffix(); + _stratum.AffixTemplates.Add( + new AffixTemplate { Slots = { new AffixTemplateSlot(suffix) { Optional = true } } } + ); + _traceManager.IsTracing = tracing; + Morpher morpher = Morpher(parallelism); + Word result = morpher.ParseWord("as").Single(); + Assert.That( + result.AllomorphsInMorphOrder.Select(a => a.Morpheme), + Is.EqualTo(new Morpheme[] { _stratum.Entries.Single(), suffix }) + ); + Assert.That(morpher.ParseWord("a").Single().AllomorphsInMorphOrder.Count(), Is.EqualTo(1)); + Assert.That(morpher.ParseWord("bs"), Is.Empty); + } + + [TestCase(false)] + [TestCase(true)] + public void TemplateSynthesis_ReportsAppliedRuleWithoutFalseRequiredFailure(bool optional) + { + AffixProcessRule suffix = Suffix(); + var template = new AffixTemplate { Slots = { new AffixTemplateSlot(suffix) { Optional = optional } } }; + Word input = new Word(Entry("a").PrimaryAllomorph, new FeatureStruct()); + input.MorphologicalRuleUnapplied(suffix); + _traceManager.AnalyzeWord(_language, input); + input.Freeze(); + Word[] output = template.CompileSynthesisRule(Morpher()).Apply(input).ToArray(); + Assert.That(output.Length, Is.EqualTo(optional ? 2 : 1)); + Trace[] slots = Events(input).Where(t => t.Type == TraceType.TemplateSlotSynthesis).ToArray(); + Assert.That(slots.Length, Is.EqualTo(optional ? 2 : 1)); + Assert.That(slots.Any(t => t.SlotOutcome == TemplateSlotOutcome.RequiredUnfilled), Is.False); + Assert.That(slots.Single(t => t.SlotOutcome == TemplateSlotOutcome.RuleApplied).SlotRule, Is.SameAs(suffix)); + } + + [Test] + public void PartialParse_StratumReportsPendingRule() + { + Entry("a"); + AffixProcessRule suffix = Suffix(); + suffix.RequiredStemName = new StemName(Pos("N")) { Name = "stem" }; + _stratum.MorphologicalRules.Add(suffix); + Assert.That(Morpher().ParseWord("as", out object trace), Is.Empty); + Trace failure = Descendants((Trace)trace).Single(t => t.FailureReason == FailureReason.PartialParse); + Assert.That(failure.PartialParseCause, Is.EqualTo(PartialParseCause.UnappliedMorphologicalRules)); + Assert.That(((PartialParseFailure)failure.FailureObject).Rule, Is.SameAs(suffix)); + } + + [Test] + public void PartialParse_FeatureConflictTakesPrecedenceOverPendingRule() + { + Entry("a"); + var realizational = new RealizationalAffixProcessRule { RealizationalFeatureStruct = Pos("V") }; + realizational.Allomorphs.Add(Suffix().Allomorphs[0]); + AffixProcessRule suffix = Suffix(); + suffix.Allomorphs[0].Rhs[1] = new InsertSegments(_table, "+d"); + _stratum.MorphologicalRules.Add(realizational); + _stratum.MorphologicalRules.Add(suffix); + bool synthesis = false; + Morpher morpher = Morpher(); + morpher.LexEntrySelector = _ => + { + synthesis = true; + return true; + }; + morpher.RuleSelector = _ => !synthesis; + Assert.That(morpher.ParseWord("asd", out object trace), Is.Empty); + Trace failure = Descendants((Trace)trace).Single(t => t.FailureReason == FailureReason.PartialParse); + Assert.Multiple(() => + { + Assert.That(failure.Output.IsAllMorphologicalRulesApplied, Is.False); + Assert.That(failure.PartialParseCause, Is.EqualTo(PartialParseCause.RealizationalFeatureMismatch)); + Assert.That(((PartialParseFailure)failure.FailureObject).Rule, Is.Null); + }); + } + + [Test] + public void LexicalLookup_CountsYieldedAllomorphCandidates() + { + Entry("a").Allomorphs.Add(new RootAllomorph(new Segments(_table, "b"))); + Assert.That(Morpher().ParseWord("a", out object trace).Count(), Is.EqualTo(1)); + Trace lookup = Descendants((Trace)trace).Single(t => t.Type == TraceType.LexicalLookup); + Assert.That(lookup.LexicalCandidateCount, Is.EqualTo(2)); + Assert.That(lookup.Children.Count(t => t.Type == TraceType.WordSynthesis), Is.EqualTo(2)); + } + + private Morpher Morpher(int parallelism = 1) => new Morpher(_traceManager, _language, parallelism); + + private FeatureStruct Pos(string id) => FeatureStruct.New(_language.SyntacticFeatureSystem).Symbol(id).Value; + + private Word AnalysisInput(string form) + { + var word = new Word(_stratum, _table.Segment(form)); + _traceManager.AnalyzeWord(_language, word); + return word; + } + + private LexEntry Entry(string form) + { + var entry = new LexEntry + { + Id = form, + SyntacticFeatureStruct = Pos("N"), + Allomorphs = { new RootAllomorph(new Segments(_table, form)) }, + }; + _stratum.Entries.Add(entry); + return entry; + } + + private AffixProcessRule Suffix() + { + var rule = new AffixProcessRule { Id = "suffix", Gloss = "suffix" }; + rule.Allomorphs.Add( + new AffixProcessAllomorph + { + Lhs = { Pattern.New("stem").Annotation(Any).OneOrMore.Value }, + Rhs = { new CopyFromInput("stem"), new InsertSegments(_table, "+s") }, + } + ); + return rule; + } + + private CompoundingRule Compound() + { + var rule = new CompoundingRule { Name = "compound" }; + rule.Subrules.Add( + new CompoundingSubrule + { + HeadLhs = { Pattern.New("head").Annotation(Any).OneOrMore.Value }, + NonHeadLhs = { Pattern.New("nonhead").Annotation(Any).OneOrMore.Value }, + Rhs = { new CopyFromInput("head"), new InsertSegments(_table, "+"), new CopyFromInput("nonhead") }, + } + ); + _stratum.MorphologicalRules.Add(rule); + return rule; + } + + private static IEnumerable Events(Word word) => Descendants((Trace)word.CurrentTrace); + + private static IEnumerable Descendants(Trace trace) + { + foreach (Trace child in trace.Children) + { + yield return child; + foreach (Trace descendant in Descendants(child)) + yield return descendant; + } + } +} From 371ba39e8bbaea24e98ba85c467909506653a7c6 Mon Sep 17 00:00:00 2001 From: John Lambert Date: Fri, 9 Oct 2026 16:29:39 -0400 Subject: [PATCH 2/2] Report new compounding rejections only to IDetailedTraceManager ITraceManager implementations previously received CompoundingRuleNotUnapplied only for the productivity-restriction rejection, with subrule index -1. This branch had sent them every new compounding gate as well. FieldWorks writes a trace node for each call, so Try A Word would have gained a reasonless failure node per rejected compound analysis. They now receive exactly the original call. Detailed consumers still get every gate with its subrule index. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../AnalysisCompoundingRule.cs | 5 +- .../TraceManagerExtensions.cs | 19 ++++++++ .../TraceReasonTests.cs | 46 +++++++++++++++++++ 3 files changed, 67 insertions(+), 3 deletions(-) diff --git a/src/SIL.Machine.Morphology.HermitCrab/MorphologicalRules/AnalysisCompoundingRule.cs b/src/SIL.Machine.Morphology.HermitCrab/MorphologicalRules/AnalysisCompoundingRule.cs index 693b8ddd7..c9267ead7 100644 --- a/src/SIL.Machine.Morphology.HermitCrab/MorphologicalRules/AnalysisCompoundingRule.cs +++ b/src/SIL.Machine.Morphology.HermitCrab/MorphologicalRules/AnalysisCompoundingRule.cs @@ -112,8 +112,7 @@ RootAllomorph allo in _morpher.SearchRootAllomorphs(_rule.Stratum, outWord.Curre Word tempInput = outWord.Clone(); tempInput.CurrentNonHead.RootAllomorph = allo; tempInput.CurrentTrace = input.CurrentTrace; - _morpher.TraceManager.CompoundingRuleNotUnapplied( - _rule, + TraceFailure( i, tempInput, FailureReason.NonHeadProdRestrictMprFeatures, @@ -196,7 +195,7 @@ out FeatureStruct syntacticFS private void TraceFailure(int index, Word input, FailureReason reason, object failureObj) { if (_morpher.TraceManager.IsTracing) - _morpher.TraceManager.CompoundingRuleNotUnapplied(_rule, index, input, reason, failureObj); + _morpher.TraceManager.CompoundingRuleRejected(_rule, index, input, reason, failureObj); } } } diff --git a/src/SIL.Machine.Morphology.HermitCrab/TraceManagerExtensions.cs b/src/SIL.Machine.Morphology.HermitCrab/TraceManagerExtensions.cs index 0b48ef1ae..62803e38c 100644 --- a/src/SIL.Machine.Morphology.HermitCrab/TraceManagerExtensions.cs +++ b/src/SIL.Machine.Morphology.HermitCrab/TraceManagerExtensions.cs @@ -17,6 +17,25 @@ object failureObj traceManager.MorphologicalRuleNotUnapplied(rule, subruleIndex, input); } + /// + /// ITraceManager implementations previously received only the productivity-restriction rejection, + /// with no subrule index, so they keep exactly that and nothing more. + /// + public static void CompoundingRuleRejected( + this ITraceManager traceManager, + IMorphologicalRule rule, + int subruleIndex, + Word input, + FailureReason reason, + object failureObj + ) + { + if (traceManager is IDetailedTraceManager) + traceManager.CompoundingRuleNotUnapplied(rule, subruleIndex, input, reason, failureObj); + else if (reason == FailureReason.NonHeadProdRestrictMprFeatures) + traceManager.CompoundingRuleNotUnapplied(rule, -1, input, reason, failureObj); + } + public static void LexicalLookupCompleted( this ITraceManager traceManager, Stratum stratum, diff --git a/tests/SIL.Machine.Morphology.HermitCrab.Tests/TraceReasonTests.cs b/tests/SIL.Machine.Morphology.HermitCrab.Tests/TraceReasonTests.cs index 83ef471ed..99e48291a 100644 --- a/tests/SIL.Machine.Morphology.HermitCrab.Tests/TraceReasonTests.cs +++ b/tests/SIL.Machine.Morphology.HermitCrab.Tests/TraceReasonTests.cs @@ -313,6 +313,52 @@ public void LegacyTraceManager_ReceivesExistingCallbacks() ); } + [TestCase("MaxStemCount")] + [TestCase("NonHeadLexicalLookup")] + [TestCase("NonHeadRequiredSyntacticFeatureStruct")] + [TestCase("NonHeadProdRestrictMprFeatures")] + public void LegacyTraceManager_ReceivesOnlyTheExistingCompoundingRejection(string reason) + { + ITraceManager legacy = System.Reflection.DispatchProxy.Create(); + var proxy = (LegacyTraceProxy)legacy; + proxy.Target = _traceManager; + CompoundingRule rule = Compound(); + if (reason != "NonHeadLexicalLookup") + Entry("b"); + if (reason == "NonHeadRequiredSyntacticFeatureStruct") + rule.NonHeadRequiredSyntacticFeatureStruct = Pos("V"); + else if (reason == "NonHeadProdRestrictMprFeatures") + rule.NonHeadProdRestrictionsMprFeatures.Add(new MprFeature { Name = "required" }); + // the morpher indexes root allomorphs when constructed, so it is built after the entries + var morpher = new Morpher(legacy, _language, 1); + Morpher detailedMorpher = Morpher(); + if (reason == "MaxStemCount") + morpher.MaxStemCount = detailedMorpher.MaxStemCount = 1; + Word input = AnalysisInput("ab"); + input.Freeze(); + Word detailedInput = AnalysisInput("ab"); + detailedInput.Freeze(); + + Assert.That(rule.CompileAnalysisRule(morpher).Apply(input), Is.Empty); + Assert.That(rule.CompileAnalysisRule(detailedMorpher).Apply(detailedInput), Is.Empty); + + Assert.That( + Events(detailedInput).Single(t => t.Type == TraceType.CompoundingRuleAnalysis).FailureReason, + Is.EqualTo(Enum.Parse(reason)) + ); + Trace[] failures = Events(input).Where(t => t.Type == TraceType.CompoundingRuleAnalysis).ToArray(); + if (reason == "NonHeadProdRestrictMprFeatures") + { + Assert.That(failures.Single().FailureReason, Is.EqualTo(FailureReason.NonHeadProdRestrictMprFeatures)); + Assert.That(failures.Single().SubruleIndex, Is.EqualTo(-1)); + } + else + { + Assert.That(failures, Is.Empty); + Assert.That(proxy.Calls, Does.Not.Contain(nameof(ITraceManager.CompoundingRuleNotUnapplied))); + } + } + public class LegacyTraceProxy : System.Reflection.DispatchProxy { public ITraceManager Target { get; set; } = default!;