Skip to content

HermitCrab: investigate unstable zero-width morpheme identities across fresh processes #506

Open

Description

@johnml1135

Identical inputs have been reported to produce different morpheme identities across fresh C# processes. Until the cause is isolated, those outputs cannot reliably establish parser parity.

Who is affected: HermitCrab consumers of morpheme identities and PanGloss maintainers recording fixtures or comparing against Machine. This is not a demonstrated Rust bug.

Keep this investigation separate from #500's dropped-identity fix. The reported association with tied-node ordering is a hypothesis, not an established root cause; choosing a random seed is not a correctness fix.

Done when: pinned before/after runs reproduce the instability, a small deterministic test isolates the mechanism, and independently justified identity multisets remain correct across every exercised ordering. Add a conformance fixture without freezing an arbitrary incorrect output.


Reproduction and evidence limits

PR #500 uses root sag, A2B (N→V, adds u), B2A (V→N, adds i), and THIRD (N→V, copies input without adding segments).

Expected sagui analyses retain A2B and B2A, with and without THIRD. The reported incorrect signature drops B2A from the THIRD analysis.

The PR reports three outputs over 20 unseeded runs and 12/12 identical correct outputs after fixing the BidirList seed. These observations motivate investigation; this issue's filing did not independently repeat them.

Record exact signatures and revisions, including #500 before/after. Test annotation/tie ordering directly rather than treating repeatability under one seed as proof. PanGloss is affected through its oracle dependency even if it does not use BidirList.

Activity

  1. johnml1135 commented on Oct 23, 2026

    @johnml1135
    CollaboratorAuthor

    New reproduction on current master (25992041). It is the same phenomenon, with no compounding involved, and the measurements point at BidirList's random skip-list levels. That contradicts the claim in #500 that BidirList has been ruled out.

    What varies. Grammar conformance/edge-cases/truncate-morphotactic/grammar.xml from PR #480 (three truncation rules in an unordered stratum, with no MorphemeIds). Parsing a or ga always returns the same number of analyses (2 and 5) with the same rule-application sequences. What changes is which morphs remain annotated on the output shape. Root 32 or one of the truncation rules' zero-width morphs is sometimes missing. For example, analysis 32 → 3SGB → 3SGA comes back with morphs 3SGA,32,3SGB, 3SGA,32 or 3SGA.

    What it is not. It is not analysis deduplication, the parallel synthesize ConcurrentBag, or per-process hash seeds:

    • It happens with MaxDegreeOfParallelism = 1 at the same rate as with the default (0, unbounded).
    • It varies between repeated ParseWord calls on a single Morpher instance in one process.

    What removes it. Either change below makes every run return the full trails, where each analysis has as many morphs as morphemes applied:

    • seeding BidirList._rand (src/SIL.Machine/DataStructures/BidirList.cs:15), or
    • leaving the random levels alone and changing BidirList.Find (BidirList.cs:265-269) to return the leftmost equal node instead of the first one an express lane happens to reach.

    Mechanism, confirmed part. AnnotationList.Find(offset) searches with a fresh probe annotation (AnnotationList.cs:202, :208). The probe's ListID is -1, so Annotation.CompareTo returns 0 against every annotation with the same range (Annotation.cs:167-168). The ListID tie-break therefore does not apply to these lookups. When zero-width morphs share a node, as truncation morphs do after SynthesisAffixProcessAllomorphRuleSpec.cs:173 and :192 attach them to Shape.Last and Shape.First, which equal node Find returns depends on the skip-list levels drawn at BidirList.cs:58.

    Mechanism, hypothesis. Which caller turns that into a dropped morph is not traced. The likely path is GetNodes (AnnotationList.cs:257, :264) during the subsume branch of AnnotationList.Add (:135), reached from Word.MarkMorph's Children.AddRange (Word.cs:278). Not checked: whether the leftmost-tie change is correct in general, or whether the HermitCrab test suite passes with it.

    Reproduction and counts
    • Grammar: conformance/edge-cases/truncate-morphotactic/grammar.xml at b49433d7 (integrate-conformance-framework, PR Add a conformance suite for HermitCrab, with its adequacy argument #480).
    • Engine: origin/master at 25992041.
    • Words: a and ga. Neither is a fixture word; the fixture's own words were not checked at scale.
    • Driver: a throwaway net8.0 console app referencing src/SIL.Machine.Morphology.HermitCrab. It calls XmlLanguageLoader.Load(path), then new Morpher(new TraceManager(), lang[, dop]), then ParseWord(word). For each analysis it prints AllomorphsInMorphOrder (Morpheme.Id joined by +, plus glosses) and MorphemesInApplicationOrder.

    30 fresh processes per configuration. "Full" means every analysis has a complete morph trail.

    Word Config Distinct outputs Full
    a default parallelism (0) 9 8/30
    a MaxDegreeOfParallelism = 1 8 7/30
    ga default parallelism (0) 4 15/30
    ga MaxDegreeOfParallelism = 1 4 15/30

    Within one process.

    • 50 ParseWord("a") calls on one Morpher (dop=1), in each of three processes: 9, 9 and 9 distinct outputs.
    • 50 fresh XmlLanguageLoader.Load + Morpher constructions in one process: 9 distinct outputs, in each of two processes.

    Controls. Three processes, each doing 50 reparses at dop=1 and 20 reloads at default parallelism:

    • BidirList seeded with new Random(12345): 1 distinct output, all full.
    • Leftmost-tie Find, unseeded: 1 distinct output, all full.
    • Assigning ListID before base.Add in AnnotationList.Add: did not help; outputs still varied.

    Full output for a, which is stable under both controls:

    ++|a  morphs=3SGA,32,3SGB  app=32,3SGB,3SGA
    ++|a  morphs=3SGB,32,3SGA  app=32,3SGA,3SGB
    

    Examples of dropped trails on unpatched master:

    +|a   morphs=3SGB,32       app=32,3SGA,3SGB
    |a    morphs=3SGA          app=32,3SGB,3SGA
    +|ga  morphs=3SGC,3SGA     app=32,3SGC,3SGA
    

    The fixture word gas (++|gas;+|gas) was identical in the handful of runs made; this was not measured at scale.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions