Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 11 additions & 14 deletions src/SIL.Machine.Morphology.HermitCrab/AnalysisAffixTemplateRule.cs
Original file line number Diff line number Diff line change
@@ -1,14 +1,12 @@
using System.Collections.Generic;
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using SIL.Machine.Annotations;
using SIL.Machine.FeatureModel;
using SIL.Machine.Rules;
using SIL.ObjectModel;
#if !SINGLE_THREADED
using System;
using System.Collections.Concurrent;
using System.Threading.Tasks;
#endif

namespace SIL.Machine.Morphology.HermitCrab
{
Expand Down Expand Up @@ -47,18 +45,16 @@ public IEnumerable<Word> Apply(Word input)
inWord.Freeze();

var output = new HashSet<Word>(FreezableEqualityComparer<Word>.Default);
#if SINGLE_THREADED
ApplySlots(inWord, _rules.Count - 1, output);
#else
ParallelApplySlots(inWord, output);
#endif
if (_morpher.MaxDegreeOfParallelism == 1)
ApplySlots(inWord, _rules.Count - 1, output);
else
ParallelApplySlots(inWord, output);

foreach (Word outWord in output)
outWord.SyntacticFeatureStruct.Add(fs);
return output;
}

#if SINGLE_THREADED
private void ApplySlots(Word inWord, int index, HashSet<Word> output)
{
for (int i = index; i >= 0; i--)
Expand All @@ -78,9 +74,10 @@ private void ApplySlots(Word inWord, int index, HashSet<Word> output)
_morpher.TraceManager.EndUnapplyTemplate(_template, inWord, true);
output.Add(inWord);
}
#else

private void ParallelApplySlots(Word inWord, HashSet<Word> output)
{
ParallelOptions parallelOptions = _morpher.CreateParallelOptions();
var outStack = new ConcurrentStack<Word>();
var from = new ConcurrentStack<Tuple<Word, int>>();
from.Push(Tuple.Create(inWord, _rules.Count - 1));
Expand All @@ -90,6 +87,7 @@ private void ParallelApplySlots(Word inWord, HashSet<Word> output)
to.Clear();
Parallel.ForEach(
from,
parallelOptions,
work =>
{
bool add = true;
Expand Down Expand Up @@ -126,6 +124,5 @@ private void ParallelApplySlots(Word inWord, HashSet<Word> output)

output.UnionWith(outStack);
}
#endif
}
}
134 changes: 134 additions & 0 deletions src/SIL.Machine.Morphology.HermitCrab/AnalysisScope.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
using System.Collections.Generic;

namespace SIL.Machine.Morphology.HermitCrab
{
/// <summary>
/// Carrier for the analysis-cascade memo, threaded through <see cref="Word"/> clones
/// like <see cref="Word.CurrentTrace"/> and likewise excluded from <c>Word.FreezeImpl</c>/
/// <c>Word.ValueEquals</c>, so dedup semantics are unchanged.
/// <para>
/// One instance per <see cref="Morpher.ParseWord(string, out object)"/> call. A state key does not
/// encode the target surface word, so sharing a scope across parses of different words would be
/// unsound.
/// </para>
/// <para>
/// Not thread-safe, hence the plain collections: a scope is only installed when
/// <see cref="Morpher.MaxDegreeOfParallelism"/> is 1. Memoizing the parallel cascade would require
/// concurrent ones.
/// </para>
/// </summary>
internal sealed class AnalysisScope
{
// OOM guards; past either cap subtrees simply go unmemoized, degrading hit rate but never
// correctness. The Word budget is the load-bearing one: entry size is unbounded (a node's list
// holds every descendant, undeduplicated) and storing them keeps every intermediate of the search
// alive for the whole parse. Both tables share it. It is a coarse backstop, not a figure derived
// from measured memory.
private const int MaxMemoEntries = 100_000;
private const int MaxMemoWords = 1_000_000;

private int _storedWordCount;

public Dictionary<AnalysisStateKey, MemoEntry> Memo { get; } = new Dictionary<AnalysisStateKey, MemoEntry>();

// Same key space as Memo, different computation: the affix-template battery's result for a state
// (AnalysisStratumRule.ApplyTemplateBattery). Separate because a state can be memoized in one
// table but not the other.
public Dictionary<AnalysisStateKey, MemoEntry> TemplateMemo { get; } =
new Dictionary<AnalysisStateKey, MemoEntry>();

// Keys still under expansion on the call stack; a re-arrival at one must fall through to
// unmemoized expansion rather than read a partial entry. Defensive only -- no path reaches it
// today, since every unapplication grows the multiset the key hashes, so a key cannot recur while
// still on the stack. The template battery needs no equivalent: its call is eager.
public HashSet<AnalysisStateKey> InProgress { get; } = new HashSet<AnalysisStateKey>();

// Per-parse hit counts, folded into the owning Morpher when the parse ends. Equivalence tests
// assert on them: a memo that silently stopped firing looks exactly like a passing test.
public int MemoHits { get; set; }
public int NogoodHits { get; set; }
public int TemplateMemoHits { get; set; }
public int TemplateNogoodHits { get; set; }

/// <summary>
/// Replay shared by both memo consumers. False on a miss; on a hit
/// <paramref name="replayed"/> holds the stored results grafted onto <paramref name="query"/>, or
/// is empty for a stored-empty ("nogood") entry. The query's non-head prefix is cloned once and
/// shared across this hit's replays, which is safe because each replay freezes immediately and
/// every non-head mutation path is CheckFrozen-guarded.
/// </summary>
public bool TryReplay(
Dictionary<AnalysisStateKey, MemoEntry> table,
AnalysisStateKey key,
Word query,
out List<Word> replayed
)
{
if (!table.TryGetValue(key, out MemoEntry entry))
{
replayed = null;
return false;
}
if (entry.Results.Count == 0)
{
replayed = new List<Word>();
return true;
}
List<Word> queryNonHeadPrefix = query.CloneNonHeadsForReplay();
replayed = new List<Word>(entry.Results.Count);
foreach (Word stored in entry.Results)
{
replayed.Add(
stored.ReplayOnto(
query,
entry.MruleTrailPrefixLength,
entry.NonHeadPrefixLength,
queryNonHeadPrefix
)
);
}
return true;
}

/// <summary>
/// Records a fully-expanded result list against <paramref name="key"/>, unless either the table is
/// full or the parse's retained-Word budget cannot absorb it.
/// </summary>
public void Store(
Dictionary<AnalysisStateKey, MemoEntry> table,
AnalysisStateKey key,
Word query,
List<Word> results
)
{
if (table.Count >= MaxMemoEntries || _storedWordCount > MaxMemoWords - results.Count)
return;
_storedWordCount += results.Count;
table[key] = new MemoEntry(results, query.MorphologicalRuleTrailLength, query.NonHeadCount);
}
}

/// <summary>
/// A memoized subtree or template-battery result. An empty <see cref="Results"/> means the state was
/// proved to yield nothing. The two prefix lengths are the trail/non-head counts at the moment of the
/// write, which is where <see cref="Word.ReplayOnto"/> splits a stored result when grafting it onto a
/// new arrival.
/// <para>
/// There is deliberately no "incomplete" flag: only fully-explored subtrees may be recorded. Should a
/// step or time budget ever be added, an interrupted subtree must not be stored.
/// </para>
/// </summary>
internal sealed class MemoEntry
{
public MemoEntry(IReadOnlyList<Word> results, int mruleTrailPrefixLength, int nonHeadPrefixLength)
{
Results = results;
MruleTrailPrefixLength = mruleTrailPrefixLength;
NonHeadPrefixLength = nonHeadPrefixLength;
}

public IReadOnlyList<Word> Results { get; }
public int MruleTrailPrefixLength { get; }
public int NonHeadPrefixLength { get; }
}
}
125 changes: 125 additions & 0 deletions src/SIL.Machine.Morphology.HermitCrab/AnalysisStateKey.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
using System;
using System.Collections.Generic;
using SIL.Machine.Annotations;
using SIL.Machine.FeatureModel;

namespace SIL.Machine.Morphology.HermitCrab
{
/// <summary>
/// Order-independent identity of an analysis-cascade node. Two Words with an equal
/// key must make identical decisions in every analysis-side rule the cascade can invoke; that is the
/// memo's correctness contract, so this key-completeness audit of what each rule reads has to be
/// re-run whenever an <c>Analysis*.cs</c> rule changes:
/// <list type="bullet">
/// <item><see cref="MorphologicalRules.AnalysisAffixProcessRule"/>: Shape (FST pattern match),
/// <see cref="Word.SyntacticFeatureStruct"/> (unifiability gate), per-rule unapplication count.</item>
/// <item><see cref="MorphologicalRules.AnalysisCompoundingRule"/>: adds <see cref="Word.NonHeadCount"/>
/// (<c>MaxStemCount</c> gate) -- never the non-heads' own content, only the count.</item>
/// <item><see cref="MorphologicalRules.AnalysisRealizationalAffixProcessRule"/>: adds
/// <see cref="Word.RealizationalFeatureStruct"/>.</item>
/// </list>
/// No rule reads the order those rules were unapplied in, which is the redundancy this key collapses,
/// so the trail is reduced to an unordered multiset here. <c>_isLastAppliedRuleFinal</c> and
/// <c>IsPartial</c> are excluded as well: <c>Word.ValueEquals</c> includes them for result dedup, but
/// no analysis-side rule reads them.
/// </summary>
internal readonly struct AnalysisStateKey : IEquatable<AnalysisStateKey>
{
private readonly Shape _shape;
private readonly Stratum _stratum;
private readonly FeatureStruct _syntacticFS;
private readonly FeatureStruct _realizationalFS;
private readonly int _nonHeadCount;
private readonly IReadOnlyDictionary<IMorphologicalRule, int> _ruleCounts;
private readonly int _hashCode;

/// <summary>
/// Keys <paramref name="word"/>, freezing the fields the key reads. A named factory because that
/// freeze mutates <paramref name="word"/>: <c>Word.FreezeImpl</c> leaves
/// <c>SyntacticFeatureStruct</c> unfrozen and <c>AnalysisAffixTemplateRule.Apply</c> mutates it on
/// already-frozen Words, so pinning it here turns a later mutation into a throw rather than a
/// corrupted table -- at the cost of freezing it earlier than the unmemoized engine does.
/// </summary>
public static AnalysisStateKey PinAndKey(Word word)
{
return new AnalysisStateKey(word);
}

private AnalysisStateKey(Word word)
{
// The cached hash covers live references -- notably Word.UnappliedRuleCounts, the word's own
// mutable dictionary. Keying an unfrozen word would let a later mutation invalidate a stored
// key's hash, silently causing permanent misses or entries that no longer match their bucket.
if (!word.IsFrozen)
throw new ArgumentException(
"The word must be frozen before it can be used as a memo key.",
nameof(word)
);

_shape = word.Shape;
_stratum = word.Stratum;
_syntacticFS = word.SyntacticFeatureStruct;
_realizationalFS = word.RealizationalFeatureStruct;
_nonHeadCount = word.NonHeadCount;
_ruleCounts = word.UnappliedRuleCounts;

// See PinAndKey for why the key pins these rather than just reading them.
_shape.Freeze();
_syntacticFS.Freeze();
_realizationalFS.Freeze();

int hash = 17;
hash = hash * 31 + _shape.GetFrozenHashCode();
hash = hash * 31 + (_stratum?.GetHashCode() ?? 0);
hash = hash * 31 + _syntacticFS.GetFrozenHashCode();
hash = hash * 31 + _realizationalFS.GetFrozenHashCode();
hash = hash * 31 + _nonHeadCount;
if (_ruleCounts != null)
{
// XOR rather than the usual *31 rolling combine: the multiset is unordered, so entries
// accumulated in different unapplication orders must still hash identically.
int multisetHash = 0;
foreach (KeyValuePair<IMorphologicalRule, int> kvp in _ruleCounts)
multisetHash ^= (kvp.Key.GetHashCode() * 397) ^ kvp.Value;
hash = hash * 31 + multisetHash;
}
_hashCode = hash;
}

public override int GetHashCode() => _hashCode;

public override bool Equals(object obj) => obj is AnalysisStateKey other && Equals(other);

public bool Equals(AnalysisStateKey other)
{
if (_hashCode != other._hashCode)
return false;
if (_nonHeadCount != other._nonHeadCount || !ReferenceEquals(_stratum, other._stratum))
return false;
if (!_shape.ValueEquals(other._shape))
return false;
if (!_syntacticFS.ValueEquals(other._syntacticFS) || !_realizationalFS.ValueEquals(other._realizationalFS))
return false;
return RuleCountsEqual(_ruleCounts, other._ruleCounts);
}

private static bool RuleCountsEqual(
IReadOnlyDictionary<IMorphologicalRule, int> a,
IReadOnlyDictionary<IMorphologicalRule, int> b
)
{
int aCount = a?.Count ?? 0;
int bCount = b?.Count ?? 0;
if (aCount != bCount)
return false;
if (aCount == 0)
return true;
foreach (KeyValuePair<IMorphologicalRule, int> kvp in a)
{
if (!b.TryGetValue(kvp.Key, out int otherCount) || otherCount != kvp.Value)
return false;
}
return true;
}
}
}
Loading
Loading