Skip to content
Closed
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
49 changes: 49 additions & 0 deletions docs/hermitcrab-synthesis-fold-probes.md
Original file line number Diff line number Diff line change
Expand Up @@ -626,3 +626,52 @@ Commit `1a7d484c` bundles the harness reporting change with the section 8 analys
agent that wrote the reporting change stopped with it uncommitted and the work was swept in when
the analysis was committed. Two concerns in one commit; flagged here rather than rewritten, since
the PR body is the review artifact.
---

## 10. The build was made, and it does not pay — because the redundancy is not real

`feature/synthesis-fold-sharing` (`1048c742`) implements the fold-step memo with a sound key:
`SynthesisStateKey` carries the **ordered remaining trail**, not just its index, plus shape,
syntactic FS, realizational FS, MPR set, root allomorph, disjunctive allomorph indices, applied
counts, `IsPartial`, `IsLastAppliedRuleFinal` and stratum — each field justified against its reader.

**Parity: 0 divergences across all 33 conformance fixtures.** The implementation is correct.

**Measured (warm-up discarded, min of 5 interleaved samples per arm):**

| fixture | predicted ceiling | realised | memo hits | off-arm spread |
| --- | --- | --- | --- | --- |
| deep-optional-affix-nesting | 50.4% (~2x) | **0.96x** (4.5% slower) | **0** | 38.6% |
| suffixing-evidential-adjacency-chain | 52.0% (~2x) | 1.06x (5.4%) | 2,682 | 21.9% |

**`hits = 0` on the fixture with the largest reliable sample and the highest predicted ceiling.**
With a trail-complete key the memo never fires there at all; the 0.96x is pure key-construction
overhead. On the evidential chain it does fire 2,682 times and returns 5.4% against 21.9% noise —
nothing.

### The structural finding

This is the same result arriving a third time, by a third independent route:

| measurement | key | apparent | sound |
| --- | --- | --- | --- |
| F1 synthesis-input dedupe | order-insensitive | 9,774x | 15–40% |
| N1 fold-entry census | trail position only | 6,476x | not established |
| **P1c fold-step sharing** | **trail position only** | **3.22x / 8.10x** | **hits=0 / 1.06x** |

**The redundancy in HermitCrab's synthesis is apparent, not real. The trail is what makes each
step distinct, and every measurement that shows large shareable work is measuring a key that omits
it.** Three different boundaries, three spectacular ratios, three collapses under a complete key.

That closes a family, not just a candidate: packed parse forests, fold-step sharing, and
synthesis-input dedupe all depend on distinct derivations converging on a genuinely identical
state, and in this engine they do not converge. It is the same fact that makes the rules
non-order-invariant, seen from the other side.

### Recommendation

**Do not merge the fold-step memo as a performance feature.** It is correct, it is off by default,
and it buys nothing — on the best case it is 4% slower. Keep the branch as the evidence that
closes the family.

The measurement infrastructure is the durable asset and should merge on its own.
33 changes: 33 additions & 0 deletions src/SIL.Machine.Morphology.HermitCrab/Morpher.cs
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ public class Morpher : IMorphologicalAnalyzer, IMorphologicalGenerator
private long _nogoodHits;
private long _templateMemoHits;
private long _templateNogoodHits;
private long _synthesisFoldHits;

public Morpher(ITraceManager traceManager, Language lang, int maxDegreeOfParallelism = 0)
{
Expand Down Expand Up @@ -89,6 +90,19 @@ public ITraceManager TraceManager
/// </summary>
public bool MergeEquivalentAnalyses { get; set; }

/// <summary>
/// Turns on the synthesis fold-step memo (docs/hermitcrab-synthesis-fold-probes.md), which shares
/// <see cref="MorphologicalRules.SynthesisAffixProcessRule"/>/
/// <see cref="MorphologicalRules.SynthesisRealizationalAffixProcessRule"/> applications across
/// candidates that reach an equal <see cref="SynthesisStateKey"/>. Defaults to <c>false</c>: this is
/// a plain A/B toggle so both configurations can be measured in one process, not a change to any
/// default behaviour. Only takes effect in <c>Morpher.SynthesizeSequential</c>
/// (<see cref="MaxDegreeOfParallelism"/> == 1, untraced) -- same restriction as the analysis-cascade
/// memo, and for the same reason: <see cref="SynthesisFoldScope"/>'s table is a plain
/// <see cref="Dictionary{TKey,TValue}"/>, not thread-safe.
/// </summary>
public bool UseSynthesisFoldMemo { get; set; }

/// <summary>
/// Caps the concurrency used within a single parse or generation -- analysis cascade,
/// affix-template unapplication and synthesis alike. A value of 1 runs the work fully
Expand Down Expand Up @@ -125,6 +139,13 @@ internal ParallelOptions CreateParallelOptions(int uncappedDegree = -1)
internal long NogoodHits => Interlocked.Read(ref _nogoodHits);
internal long TemplateMemoHits => Interlocked.Read(ref _templateMemoHits);
internal long TemplateNogoodHits => Interlocked.Read(ref _templateNogoodHits);
internal long SynthesisFoldHits => Interlocked.Read(ref _synthesisFoldHits);

// Interlocked because one Morpher may be parsing on several threads at once.
private void AccumulateSynthesisFoldDiagnostics(SynthesisFoldScope scope)
{
Interlocked.Add(ref _synthesisFoldHits, scope.Hits);
}

// Interlocked because one Morpher may be parsing on several threads at once.
private void AccumulateMemoDiagnostics(AnalysisScope scope)
Expand Down Expand Up @@ -366,6 +387,13 @@ private IEnumerable<Word> Synthesize(string word, ConcurrentQueue<Word> analyses
private IEnumerable<Word> SynthesizeSequential(string word, IEnumerable<Word> analyses)
{
var matches = new HashSet<Word>(FreezableEqualityComparer<Word>.Default);
// One scope for the whole surface-word parse, shared across every analysis word's alternatives
// -- exactly the scope P1c/N1 measured sharing over (docs/hermitcrab-synthesis-fold-probes.md).
// Never while tracing, matching AnalysisScope's own restriction: traces must stay byte-identical
// to the unmemoized engine. This method is only ever reached when MaxDegreeOfParallelism == 1
// (see Synthesize), so no separate parallelism check is needed here.
SynthesisFoldScope foldScope =
UseSynthesisFoldMemo && !_traceManager.IsTracing ? new SynthesisFoldScope() : null;
int alternativeCount = 0;
foreach (Word analysisWord in analyses)
{
Expand Down Expand Up @@ -413,6 +441,9 @@ private IEnumerable<Word> SynthesizeSequential(string word, IEnumerable<Word> an
if (MaxAlternatives > 0 && alternativeCount > MaxAlternatives)
throw new MaxAlternativesExceededException("MaxAlternatives exceeded");

if (foldScope != null)
alternative.SynthesisFoldScope = foldScope;

if (!SynthesisProbe.Enabled)
{
foreach (Word validWord in _synthesisRule.Apply(alternative).Where(IsWordValid))
Expand Down Expand Up @@ -447,6 +478,8 @@ private IEnumerable<Word> SynthesizeSequential(string word, IEnumerable<Word> an
}
}
}
if (foldScope != null)
AccumulateSynthesisFoldDiagnostics(foldScope);
return matches;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,12 +40,40 @@ public SynthesisAffixProcessRule(Morpher morpher, AffixProcessRule rule)

public IEnumerable<Word> Apply(Word input)
{
// The trail-position gate stays outside the memo unconditionally, on both the memoized and
// unmemoized paths: it is an O(1) index-plus-reference check (docs/hermitcrab-synthesis-fold-probes.md
// section 3's "~40x free" observation), so a SynthesisStateKey is never worth constructing for
// the ~72-73% of rejections that die here (section 6.1's P1b histogram).
if (!input.IsMorphologicalRuleApplicable(_rule))
{
SynthesisProbe.RecordDie(SynthesisDiePoint.RuleNotApplicableOrPatternMismatch);
return Enumerable.Empty<Word>();
}

SynthesisFoldScope foldScope = _morpher.UseSynthesisFoldMemo ? input.SynthesisFoldScope : null;
if (foldScope == null)
return ApplyMatchingAllomorphs(input);

SynthesisStateKey key = SynthesisStateKey.PinAndKey(input);
if (foldScope.TryGet(key, _rule, out IReadOnlyList<Word> stored))
{
foldScope.Hits++;
var replayed = new List<Word>(stored.Count);
foreach (Word storedOutput in stored)
replayed.Add(storedOutput.ReanchorSynthesisStep(input, trailConsuming: true));
return replayed;
}

var computed = ApplyMatchingAllomorphs(input).ToList();
foldScope.Store(key, _rule, computed);
return computed;
}

// Everything past the trail-position gate: this is the expensive part of the step (per-allomorph
// MPR checks, pattern matching, unification), and everything it reads is covered by
// SynthesisStateKey -- see that class's doc comment for the field-by-field audit.
private IEnumerable<Word> ApplyMatchingAllomorphs(Word input)
{
if (input.GetApplicationCount(_rule) >= _rule.MaxApplicationCount)
{
if (_morpher.TraceManager.IsTracing)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,31 @@ public IEnumerable<Word> Apply(Word input)
if (!_morpher.RuleSelector(_rule))
return Enumerable.Empty<Word>();

SynthesisFoldScope foldScope = _morpher.UseSynthesisFoldMemo ? input.SynthesisFoldScope : null;
if (foldScope == null)
return ApplyUncached(input);

SynthesisStateKey key = SynthesisStateKey.PinAndKey(input);
if (foldScope.TryGet(key, _rule, out IReadOnlyList<Word> stored))
{
foldScope.Hits++;
var replayed = new List<Word>(stored.Count);
foreach (Word storedOutput in stored)
// Realizational rules are trail-exempt (no IsMorphologicalRuleApplicable gate), so a
// successful application never advances PendingTrailPosition.
replayed.Add(storedOutput.ReanchorSynthesisStep(input, trailConsuming: false));
return replayed;
}

var computed = ApplyUncached(input).ToList();
foldScope.Store(key, _rule, computed);
return computed;
}

// Everything SynthesisStateKey is audited against: the "at most once" and subsumption/blocking
// gates, per-allomorph MPR checks, pattern matching, unification.
private IEnumerable<Word> ApplyUncached(Word input)
{
// 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.
Expand Down
88 changes: 88 additions & 0 deletions src/SIL.Machine.Morphology.HermitCrab/SynthesisFoldScope.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
using System;
using System.Collections.Generic;

namespace SIL.Machine.Morphology.HermitCrab
{
/// <summary>
/// Carrier for the synthesis fold-step memo (docs/hermitcrab-synthesis-fold-probes.md). Same shape as
/// <see cref="AnalysisScope"/>: one instance per <see cref="Morpher.ParseWord(string, out object)"/>
/// call, threaded through <see cref="Word.SynthesisFoldScope"/>, not installed while tracing or when
/// running with more than one degree of parallelism (plain <see cref="Dictionary{TKey,TValue}"/>, not
/// thread-safe).
/// <para>
/// A memo entry is keyed on (<see cref="SynthesisStateKey"/>, applied rule) and holds a SET of output
/// Words -- not one value -- because several allomorphs of one rule can legitimately all pattern-match
/// one input before a disjunctive break (see the doc comment on <c>SynthesisAffixProcessRule.Apply</c>'s
/// call to <c>SynthesisProbe.RecordApplications</c>, which records fold steps the same way for the same
/// reason). Stored Words are never handed to a caller directly: every read goes through
/// <see cref="Word.ReanchorSynthesisStep"/>, which re-parents the stored result onto the querying
/// candidate's own trail/non-head identity.
/// </para>
/// <para>
/// Only successful steps are stored, mirroring <see cref="SynthesisProbe.RecordApplications"/>: a
/// trail-position mismatch (<c>IsMorphologicalRuleApplicable</c> false) is an O(1) index-plus-reference
/// check the plan doc itself characterizes as "not 95% of anything" at ~29ns, so memoizing it would
/// spend a scarce entry slot buying almost nothing. Empty results *past* that cheap gate (a rule that
/// was trail-eligible but whose allomorphs all failed to unify/pattern-match) ARE stored, because
/// reaching that verdict is exactly the expensive work (unification, pattern matching) this memo exists
/// to share.
/// </para>
/// </summary>
internal sealed class SynthesisFoldScope
{
// Same cap philosophy as AnalysisScope.MaxMemoEntries: a coarse backstop, not a figure derived from
// measured memory. Correctness never depends on it -- past the cap, new fold steps are simply
// computed and returned unmemoized, same as when the scope itself is null.
private const int MaxMemoEntries = 100_000;

private readonly Dictionary<SynthesisFoldStepKey, IReadOnlyList<Word>> _memo =
new Dictionary<SynthesisFoldStepKey, IReadOnlyList<Word>>();

/// <summary>Per-parse hit count, folded into the owning Morpher when the parse ends.</summary>
public int Hits { get; set; }

// Diagnostic totals for the A/B harness: distinguishes "the memo never hits" (the idea is dead)
// from "the memo hits but the key costs more than the step it saves" (the key is the problem).
// Free-running; a harness reads the delta.
internal static long DiagHits;
internal static long DiagStores;
internal static long DiagLookups;

public bool TryGet(SynthesisStateKey key, IMorphologicalRule rule, out IReadOnlyList<Word> outputs)
{
DiagLookups++;
bool hit = _memo.TryGetValue(new SynthesisFoldStepKey(key, rule), out outputs);
if (hit)
DiagHits++;
return hit;
}

public void Store(SynthesisStateKey key, IMorphologicalRule rule, IReadOnlyList<Word> outputs)
{
if (_memo.Count >= MaxMemoEntries)
return;
DiagStores++;
_memo[new SynthesisFoldStepKey(key, rule)] = outputs;
}

private readonly struct SynthesisFoldStepKey : IEquatable<SynthesisFoldStepKey>
{
private readonly SynthesisStateKey _state;
private readonly IMorphologicalRule _rule;
private readonly int _hash;

public SynthesisFoldStepKey(SynthesisStateKey state, IMorphologicalRule rule)
{
_state = state;
_rule = rule;
_hash = (state.GetHashCode() * 397) ^ (rule?.GetHashCode() ?? 0);
}

public bool Equals(SynthesisFoldStepKey other) => _rule == other._rule && _state.Equals(other._state);

public override bool Equals(object obj) => obj is SynthesisFoldStepKey k && Equals(k);

public override int GetHashCode() => _hash;
}
}
}
Loading
Loading