This document specifies, precisely enough that a third party should not need to read any engine's source (or this repo's), what it takes for any morphological/phonological parser to become testable against the fixtures in this repository. The protocol is engine-agnostic: nothing here, or in a fixture's own files, requires any engine's build, layout, or language — only a way to run the engine under test as a command and produce a plain TSV file.
The C# HermitCrab implementation in sillsdev/machine (SIL.Machine.Morphology.HermitCrab) is this suite's
founding oracle: every fixture's ground truth was generated by running it, and the batch command of its
command-line tool (class BatchCommand) is where this contract's TSV format and signature algorithm were
first implemented — but HermitCrab is not a privileged consumer of the protocol, just its first
implementer (the Rust port's own CLI independently arrived at the same
3-arg batch contract). Any engine, for any language, implementing section 1 below is equally
conformant.
Below, the harness means the program that runs an engine against the fixtures and diffs the result:
today sillsdev/machine's conformance runner (hc-conformance) and PanGloss's equivalent. The harness is not
part of this repository; this document specifies what it must do so that two harnesses agree.
A conforming engine adapter is a single executable command:
<engine-binary> batch <grammar> <words.txt> <output.tsv> [--start N]
<grammar>— the engine's grammar input for this fixture. For the reference representation this isgrammar.xml(HermitCrabInputXML); see section 6 for engines that cannot consume it directly.<words.txt>— one word per line, blank lines ignored; shared verbatim across every engine under test for a given fixture (never engine-specific).<output.tsv>— the file the adapter must write its results to, in the format specified in section 2.--start N(optional) — 0-based line index to resume at, for crash recovery on long word lists. An adapter that doesn't need resumability may ignore this flag or not implement it; the harness only relies on it for its own optional resume behavior, never as a correctness requirement.
This is exactly what hc-rs batch <grammar.xml> <words.txt> <output.tsv> already does, so the Rust
port is adapter-conformant today with zero wrapper code. See section 7 for the one C#-specific
wrinkle, and section 8 before implementing an engine from scratch: grammar.xml alone
under-specifies the ground truth, because several Morpher host-configuration values that are not
part of the grammar file were baked into every fixture's expected.tsv.
Every line of <output.tsv> has exactly 5 tab-separated columns:
idx word ms status signature
idx— 0-based index of the word inwords.txt(matches the founding oracle's batch loop index).word— the literal word string being parsed (echoed back, so the row is self-describing without needing to cross-referencewords.txtby line number).ms— elapsed wall-clock milliseconds for parsing this one word. Not compared by the harness (see section 3) — timing is informational only and is expected to vary run to run and engine to engine.status— one of:ok— the parse attempt completed (regardless of whether it found any analyses — zero analyses is a legitimateokresult, not a failure; see the signature column).SKIPPED— the engine could not attempt the parse at all (C#'s only case today:InvalidShapeException, i.e. the word contains a character outside the grammar's character definition table). ASKIPPEDrow's signature is always-.
signature— the order-independent, sorted, semicolon-joined signature of every distinct analysis found (see section 3 for the exact algorithm), or the literal-if zero analyses were found (this is the normal, correct representation of "this word does not parse under this grammar" — a fixture'sexpect_fail: truewords).
C#'s BatchCommand writes a sentinel line immediately before attempting each word, so a crashed
process can be resumed from the last line written:
idx word STARTED
(3 columns, not 5.) This is a crash-recovery convenience for engines that might crash mid-word on a
long corpus; it carries no comparison meaning and adapters are not required to emit it (hc-rs
deliberately does not — it buffers results and writes the final file in one shot). The harness's TSV
parser and comparison logic (section 4) skip any line that doesn't have the 5-column ok/SKIPPED
shape, so STARTED lines are silently ignored wherever they appear.
A handful of fixtures (e.g. edge-cases/simultaneous-epenthesis-cascade, whose founding oracle run
hit an infinite-loop detector mid-word) pin a crash as their ground truth, not a signature. Such
a fixture's expected.tsv contains only a STARTED sentinel line for the word that crashed the
oracle — there is no real result row, because the oracle itself died first — and its fixture
metadata (words.yaml front matter) carries expect_crash: true to make that explicit rather than
leaving the harness to infer it from an empty expected.tsv.
The harness's contract for an expect_crash fixture:
- The engine under test is expected to also crash while loading the grammar or parsing the word (not necessarily the same exception type or message — just fail rather than complete). That counts as a PASS: reproducing the crash IS conforming to the ground truth.
- If the engine crashes but
expect_crashis not set, that is a FAIL (an unexpected engine crash), with the engine's diagnostics shown. - If
expect_crashis set but the engine returns normally (produces output instead of crashing), that is a FAIL ("expected crash, engine produced output") — the engine no longer reproduces the bug the fixture pins. - A harness-level problem (bad
--adaptercommand, the adapter process failing to start, a timeout, or an adapter exiting 0 without producing its output file) is never treated as a crash match,expect_crashor not — it is always a plain failure/error.
Because a crash aborts a fixture's whole word loop, crash fixtures should keep words.txt to a
single word: there is nothing after the crash for the harness to compare, so additional words would
never actually be exercised.
What expect_crash actually pins, and the hazard that follows from it. For
edge-cases/simultaneous-epenthesis-cascade, the crash is InfiniteLoopException, thrown by
the oracle's epenthesis synthesis step
(EpenthesisSynthesisRewriteSubruleSpec.ApplyRhs) the instant a shape being built during synthesis reaches exactly 256 nodes. This
is not one of the host-configuration knobs in section 8 — it is a hardcoded constant, not
exposed on Morpher or anywhere else, so there is no value a reimplementing engine can set to opt
out of it. Conformance on this fixture therefore requires the engine under test to independently hit
some runaway condition on the same input and fail rather than complete — not because failing is
correct, but because the founding oracle failed here and the fixture's ground truth is that failure.
An engine that instead detects and terminates the same unbounded epenthesis expansion cleanly (e.g.
by finding a correct, bounded analysis, or by cycle detection that recovers rather than crashing) is,
on its own merits, the more correct engine — and is non-conformant on this fixture per section
4's comparison rules, because "engine returns normally" is scored as a FAIL regardless of whether the
output would have been right. Treat this fixture as pinning an implementation limitation of the
founding oracle, not a linguistic fact about the grammar it exercises.
For a word that parses, each distinct result contributes one signature entry:
<morph1.MorphemeId>+<morph2.MorphemeId>+...|<shape-as-regex-string>
- The morpheme-ID chain is built by walking the word's allomorphs in morph order
(
Word.AllomorphsInMorphOrder), taking each allomorph's owning morpheme'sId, joined with+. - After the
|, the surface shape is rendered viaShape.ToRegexString(word.Stratum.CharacterDefinitionTable, true)— the same character-definition table used to load the grammar, with boundary markers included (the trailingtrueargument). - All of a word's distinct-analysis signature strings are then sorted ordinally
(
StringComparer.Ordinal) and joined with;. This makes the signature order-independent: two runs that find the same set of analyses in a different internal search order compare equal. It does not dedupe — if an engine's search legitimately produces the same signature string twice (a duplicate, not a distinct analysis), both occurrences appear; a divergence in duplicate count for an otherwise-matching signature set is not automatically a pass, see section 4 for how the harness handles this. - If zero analyses are found, the signature is the literal string
-(not an empty string).
This is a direct transcription of the founding oracle's signature builder (BuildSignature in its batch command); an adapter for a new engine
reproduces this string exactly, using that engine's own morpheme IDs and its own surface-shape
rendering, for its output to be diffable against expected.tsv.
Morpheme ID / gloss naming discipline: because the signature format compares morpheme names
literally, any engine whose grammar representation assigns different IDs to the same morphemes
(e.g. a hand-authored non-XML grammar for a fixture, see section 6) must match grammar.xml's IDs
exactly, or a real match will register as a false failure.
Multi-character segment representations render individually parenthesized. A character
definition whose string representation is more than one character (e.g. a digraph segment like
"ch") is wrapped in its own (...) in the shape half of a signature, unconditionally — this comes
straight from the oracle's ToRegexString extension, which wraps any representation longer than one
character in parentheses and does so regardless of whether the segment is otherwise ambiguous. A word "panucho" in a grammar that
defines "ch" as one segment renders as panu(ch)o, not panucho. Easy to miss when hand-deriving
an expected signature — check a grammar's character definition table for multi-character
representations before trusting a hand-written signature (see
languages/suffixing-evidential-adjacency-chain/words.yaml's authoring-discipline note for a case this
actually caught).
Guess-stem rendering (Guesser/LexicalGuess, pinned in G1). A guessed root has no
distinguishing marker at all in the signature string: Morpher.LexicalGuess builds a throwaway
LexEntry whose Id (and Gloss) is set to the literal matched surface substring, so the guessed
root's morph-chain component in the signature is that substring itself, indistinguishable by string
alone from a real lexical entry that happened to share the same id (e.g. word "foos" with root
"foo" unknown but a lexical guess pattern [Any]* in scope, plus a real suffix rule PL="-s", yields
signature entries foo+PL|foos and — because the guess pattern is also tried against the
zero-affixes candidate — foos|foos; the oracle pins this in a unit test of its own). The legacy batch command calls the two-argument SignatureFormat.ParseOneWord overload,
which disables root guessing. The harness self-check uses an explicit morpher.GuessRoots value
when present; otherwise it retains the legacy per-word rule: a guess: true parse enables guessing
for that word. Settings-aware adapters receive the per-call flag through morpher.json (section 8).
When GuessRoots is explicitly present, either true or false, adapter materialization retains
all word rows. When it is absent, words carrying a guess: true parse are omitted from the legacy
adapter input. A named variant with no adapter-visible words fails rather than passing without a
comparison.
The harness diffs an adapter's <output.tsv> against a fixture's checked-in expected.tsv using
these rules:
- Only the
word,status, andsignaturecolumns are compared.idxandmsare ignored (row identity is keyed byword, notidx, so an adapter is free to reorder rows;msis pure timing noise). - Any line that isn't a well-formed 5-column
idx / word / ms / status / signaturerow (in particular, anySTARTEDsentinel line) is skipped entirely before comparison. - A signature is parsed into its
;-separated set ofmorph-chain|shapeentries and compared as a multiset — reordering the entries within a signature string is not a failure (the algorithm already sorts them, but a hand-edited or alternate-engineexpected.tsvreordering is tolerated defensively), but a genuine difference in the entries themselves, or in how many times a given entry appears, is a failure. Within an entry's shape, the members of each bracketed class[...](and[^...]) are compared as a set, so|b[ae]tequals|b[ea]t. HermitCrab renders class members in character-definition-table insertion order, and FieldWorks fills that table from an unordered collection, so the order carries no meaning (sillsdev/machine#573). Write expected signatures in the order your engine renders; a harness comparing against a differently-ordered engine still passes. - A
statusmismatch (okvsSKIPPED) is always a failure. - A missing or extra
wordrow (present in one file, absent in the other) is a failure for that word.
Anything outside these rules — a real signature difference, a real status difference — is a genuine conformance failure and is reported as such, per word, and rolled up per fixture.
Not every engine implements every grammar-model construct HermitCrab supports (the motivating case is XAmple, SIL's AMPLE-family morphotactic parser, which has no phonological-rule engine at all). Two mechanisms make the suite serve engines of different scope without forking it:
- Engine capability declaration. Whoever invokes the harness against an engine declares that
engine's supported capability set, e.g.
--capabilities phonologyfor HermitCrab/hc-rs, or--capabilities ""(empty) for a purely morphotactic engine. Self-check mode (running the live C# oracle in-process) always implies full capability,phonologyincluded. - Fixture capability requirement. Every fixture's metadata (
words.yamlfront matter) carries arequiresarray.phonologymeans "this fixture'sgrammar.xmlcontains at least onePhonologicalRuleorMetathesisRuleelement";morpher-settingsmeans "this fixture'swords.yamldeclares amorpher:block" (section 8), which the engine must apply. An empty/absentrequiresis pure morphotactics, runnable by any conforming engine. The harness mechanically re-derives this list fromgrammar.xmlon every run and FAILs the fixture (a metadata error, not a conformance failure) if the checked-inrequiresdisagrees, so this can never silently drift.
The harness computes, for each fixture, whether the engine's declared capability set is a superset
of the fixture's requires list. If not, the fixture is skipped for that engine run — reported
as SKIPPED, distinct from both PASS and FAIL, so a capability-limited engine's run is never
scored as if it silently passed fixtures outside its scope. New capability values are added the same
way phonology was: name the vocabulary word, and mechanically derive it from the fixture's
grammar.xml (or other grammar representation) contents, never by hand-guessing from a directory
name.
The adapter contract's first positional argument is the engine's grammar input for the fixture,
not necessarily grammar.xml itself. grammar.xml (HermitCrabInput XML) is the default and
reference representation — every fixture ships one, and it's what the C# oracle used to generate
expected.tsv. An engine that cannot consume that XML directly (XAmple's case: it consumes
AMPLE-style control/dictionary files) names an alternate, per-fixture grammar file or directory in
its own registration (e.g. a grammar.xample/ directory living alongside grammar.xml in the
fixture, anticipating a future XAmple adapter).
words.txt and expected.tsv are always shared across every engine tested against a fixture: one
word list, one ground truth, regardless of how many different grammar representations exist for it.
The founding oracle's command-line tool in sillsdev/machine (hc.dll) does not implement section 1's contract
directly, because of a pre-existing CLI shape it inherited from HermitCrab.NET: grammar loading is a
global Program.Main option (-i <grammar.xml>), separate from and prior to dispatching any
subcommand, and its batch subcommand only takes 2 positional arguments
(<wordlist-file> <output-tsv>), not 3. The normal invocation is:
hc.dll -i grammar.xml -s script.txt
where script.txt contains one line: batch words.txt expected.tsv (paths relative to the fixture
directory the tool is run from). This is a real, working invocation shape — every fixture's
expected.tsv in this suite was generated exactly this way — it is just not literally section 1's
single 3-positional-arg command.
Two consequences:
- The harness's self-check mode does not shell out to
hc.dllat all. It calls theMorpherAPI in-process (loadinggrammar.xmlviaXmlLanguageLoader, building aMorpher, and computing the same signature algorithm as the batch command, see section 3) — this sidesteps the CLI-shape mismatch entirely, since self-check mode never needs a CLI contract in the first place. - If anyone ever wants to point the harness's
--adapterflag athc.dllitself (to test adapter-mode plumbing against a known-good reference, independent of self-check mode),hc.dllneeds a one-line wrapper script that (1) writes a temporaryscript.txtcontainingbatch <words.txt> <output.tsv>with the two paths substituted in, then (2) invokeshc.dll -i <grammar> -s <that temp script>. Not required by any current acceptance criteria (self-check plus--adapteragainsthc-rssuffices), but straightforward if ever needed. The wrapper script was in sillsdev/machine#480 and is not part of this repository.
grammar.xml fully determines a grammar's rules, but it does not determine every input to the
parse. The oracle's Morpher class has several settable
properties that change which analyses are found for the same grammar and word, and every one of
this suite's expected.tsv files was generated with Morpher left at its plain constructor
defaults unless the fixture's words.yaml declares a morpher: block (see "Overriding a default
per fixture" at the end of this section): both the batch command's host (HCContext.Compile)
and the harness's self-check runner
construct new Morpher(traceManager, language) and never assign any of the properties below. A
formalism-correct reimplementation that happens to pick different values for these will diverge from
expected.tsv on fixtures that exercise them, with nothing in grammar.xml to explain why. These
defaults are pinned executably against the constructor by a unit test in sillsdev/machine
(Constructor_DefaultsMatchDocumentedProtocolDefaults).
MaxStemCount— default2. Consumed in the oracle'sAnalysisCompoundingRule.Apply: once an in-progress analysis's non-head count would reachMaxStemCount, the compounding rule refuses to unapply further, so no candidate with more stems is ever produced. Observable difference: a grammar whose compounding rule can in principle chain three or more stems will only ever be analyzed as a compound of at mostMaxStemCountstems under the reference oracle; a reimplementation using a higher value will find compound analyses of words this suite'sexpected.tsvshows as unparsed or as parsed with fewer stems, and a lower value will miss compound analyses this suite'sexpected.tsvshows as found.DeletionReapplications— default0. Consumed in the oracle'sAnalysisRewriteRule.Apply(its deletion case): a deletion phonological rule always unapplies (recovers a deleted segment) once; it is then permitted to reapply and recover an additional consecutive deletion up toDeletionReapplicationsmore times before the loop stops. At the default0, exactly one deletion is ever recovered per rule invocation — a surface form that resulted from two or more consecutive applications of the same deletion rule cannot have all of them undone, so the fully-restored underlying form is never reached and that analysis is absent fromexpected.tsv. Observable difference: raising this value changes the analysis candidate set itself (not just performance) — it can surface additional valid analyses for words with multiple consecutive deletion sites that the default value'sexpected.tsvshows as not found, or as found via a different (shorter-recovery) analysis path.MaxAlternatives— default0, meaning unlimited. Every guard on this value (AnalysisStratumRule.Apply,RuleCascade,RuleBatch) is written asvalue > 0 && count > value, so0disables the check entirely rather than meaning "zero alternatives." When set to a positive number and exceeded, the analyzer throwsMaxAlternativesExceededException— and nothing between there andMorpher.ParseWord/AnalyzeWordcatches it, so it propagates out of the call entirely; there is no code path that turns an exceeded limit into a truncated-but-usable result. Observable difference: since every fixture was generated with this disabled,expected.tsvnever reflects a truncated search — a reimplementation must not impose an undocumented alternative cap of its own (even one intended purely as a runaway guard) without expecting to diverge on any fixture whose search happens to exceed it, and must not silently truncate where the reference oracle would have thrown or continued.MergeEquivalentAnalyses— defaulttrue. Consumed inAnalysisStratumRule.Apply(around line 133): while unapplying rules within one stratum, intermediate analyses that end up with identical shapes are merged into one canonicalWord, with the others recorded on itsAlternativeslist instead of being carried forward independently into the next stratum's search. Every merged alternative is expanded back out (Word.ExpandAlternatives) immediately before lexical lookup and synthesis (see the doc comment onMorpher), so this is designed to prune redundant search state rather than to change which analyses are ultimately found. No fixture in this suite is known to depend onMergeEquivalentAnalyses: falseproducing a different result thantrue, but the default is documented here because it is still host configuration this suite'sexpected.tsvwas generated under, and a reimplementation that skips the equivalent-shape merge (most will — it is a C#-side performance optimization, not part of the formalism) must still ensure it explores the same eventual analysis set that expansion restores.guessRoot— see section 3's "Guess-stem rendering" discussion above. This is a per-call flag rather than aMorpherproperty. The legacy batch command disables it; the harness supports explicitGuessRootssettings in both self-check and settings-aware adapters.LexEntrySelector/RuleSelector— defaultentry => true/rule => true. These are host-installed predicates that can exclude specific lexical entries or specific rules from participating in analysis/synthesis at all (checked in, e.g.,Morpher.LexicalLookupand in each compiled rule'sApply, such asAnalysisCompoundingRule.ApplyandAnalysisRewriteRule.Applyabove). Neither the batch command /HCContextnor the harness's self-check runner ever installs a narrower predicate, so every fixture'sexpected.tsvwas generated with every lexical entry and every rule ingrammar.xmleligible to participate — a reimplementation must not implement any equivalent of a partial-grammar or partial-lexicon mode (even for performance or scoping reasons) without expecting to diverge, since there is no signal ingrammar.xmlitself that any entry or rule was excluded.
These are the only settable, parse-affecting properties found on Morpher; XmlLanguageLoader
(which builds the Language object from grammar.xml) does not read, set, or expose any of them —
every one of the above is host-side configuration layered on top of the grammar, never something a
grammar file itself can express or override.
A fixture whose ground truth depends on a non-default setting declares it in words.yaml's front
matter, keyed by the FieldWorks <ParserParameters><HC> element name that sets it in FieldWorks
(HCParser.LoadParser), so the same names a FieldWorks project carries reach every engine:
requires: [phonology, morpher-settings]
morpher:
DelReapps: 1| Key | Morpher property |
Value | Default when absent |
|---|---|---|---|
DelReapps |
DeletionReapplications |
whole number, at least 0 | 0 |
MaxRoots |
MaxStemCount |
whole number, at least 1 | 2 |
MergeAnalyses |
MergeEquivalentAnalyses |
true or false |
true |
MakePartialsFinal |
AlwaysEnforceFinalTemplates |
true or false |
false |
GuessRoots |
per-call ParseWord(..., guessRoot) flag |
true or false |
per-word parses[].guess in self-check; otherwise false |
Rules enforced by the loader (sillsdev/machine's WordsYamlLoader). The published schema
(schema/words.schema.json) checks structural constraints; the loader also checks ordered
word-list equality, default-only coverage metadata, unique names, and exact capability equivalence:
- Absent means the defaults above, exactly as before this block existed. An empty block is an
error: omit it instead.
GuessRootsis an explicit per-call flag and is never applied to aMorpherproperty; when absent, self-check retains legacy per-wordparses[].guessbehavior. - Named run variants are an optional top-level
variantssequence. Each entry has a uniquenamematching[a-z0-9-]+(defaultis reserved), a non-emptymorpherblock, and a completewordssequence with exactly the same orderedwordvalues as the top-level sequence. Its expectations use the sameparses,expect_fail, andexpect_skipfields. Each run starts from engine constructor defaults and applies only its own settings; a variant never inherits the top-levelmorpherblock. Coverage metadata remains on the default word list. The harness runs the default and every named variant independently and labels variant diagnostics by name. - The vocabulary is closed. Any other key is an error rather than ignored, because a setting the
harness cannot apply must never fall back silently to the default it was meant to override.
MaxAlternativesremains unsupported because its limit changes search completion into an error, whileMergeMSAsrequires FieldWorks loader support; both are hard errors.NotOnClitics,NoDefaultCompounding,AcceptUnspecifiedGraphemesandStratashape how FieldWorks builds aLanguage, whichgrammar.xmlalready states directly. - A settings block is a capability requirement. A fixture with a default or variant
morpherblock must listmorpher-settingsinrequires, and the harness re-derives that fromwords.yamlthe same way it derivesphonologyfromgrammar.xml(section 5): an engine that does not declaremorpher-settingsis reportedSKIPPEDfor that run, never run at defaults. Self-check declares it. - How an adapter receives the values. Each run is materialized separately and writes its own
morpher.jsonbesidegrammar.xml(a JSON object of the same keys;{}for a default run with no block), and the harness substitutes its path for a{morpher}placeholder in the--adaptercommand template. Guessed-root rows are materialized only whenGuessRootsis explicit, so an adapter cannot silently appear to support guessing from omitted rows. An adapter that declaresmorpher-settingsmust take the placeholder; the harness fails the run, naming the missing placeholder, rather than run it without the settings. - Counterfactual and coverage gates that rebuild a mutated grammar in a child process
(
CounterfactualGate, from sillsdev/machine#480) still evaluate every fixture at the defaults; their surface deltas for a fixture with a block are therefore measured under the defaults, not under the block.
edge-cases/deletion-reapplications is the first fixture to use it (DelReapps: 1), observed from
the C# oracle at 0, 1 and 2 to show the setting is what moves its expected parses.
Passing this suite proves an engine matches the founding oracle over grammar.xml; it says nothing
by itself about whether any real project could have produced that grammar.xml. FieldWorks users
never author HermitCrabInput XML directly — their grammars come out of HCLoader.cs
(Src/LexText/ParserCore/HCLoader.cs in the FieldWorks repository), which builds a Language
straight from a LibLCM project and has a materially narrower reach than the DTD or the engine it
feeds. A fixture can therefore be a fully conformant, oracle-verified regression test of the engine
and still describe a grammar shape no FieldWorks user will ever see.
The standard for "producible" is the round trip: a real FieldWorks project, built in FieldWorks, goes through
HCLoader.cs and yields the fixture's grammar.xml. That the loader source could write a construct is weaker
evidence. fieldworks_producible: true is therefore a claim read from the loader until the fixture has a
FieldWorks witness (section 10), and only the fixtures with a witness are certified today: 9 of the 134
(all under edge-cases/).
Every fixture's words.yaml front matter carries fieldworks_producible: true, directly after
requires. The suite keeps only grammars a real FieldWorks project can produce: true means every
construct the fixture's grammar.xml exercises has a known HCLoader code path that can emit it from
a real project. A grammar that needs a construct with no such path is rewritten to drop it in favour
of an equivalent FieldWorks can produce, or it is deleted; scripts/check_words_schema.py and the
consuming engines' gates fail on any fixture that is not true. The loader still parses false (with fieldworks_producible_notes) so a
research fixture kept outside this tree can carry the verdict, but nothing in this repository may.
A fixture moves to true by rewriting its grammar and re-running the C# founding oracle, never by
relaxing the verdict in place; the rewrite keeps a fixture only if the oracle's rows still hold or
change for a reason the rules explain. See docs/engine-only-fixture-retirement.md for the decision
and what it removed, and the section "What the XML can say but FieldWorks cannot, so we do not
test it" of docs/fieldworks-cannot-say.md for the constructs and the HCLoader.cs lines that rule them out.
The verdict is human-authored, not mechanically re-derived the way requires is. It rests on
fieldworks-producibility.tsv (a hand-researched, point-in-time snapshot of HCLoader.cs,
from sillsdev/machine#480, not in this repository) plus five usage-level checks no per-row TSV
entry can express on its own — three because each depends on where or how an otherwise-producible
attribute is used, not merely on whether HCLoader ever writes it at all; a fourth because it depends
on whether a fixture's own committed ground truth is sensitive to a value HCLoader can never produce;
and a fifth because it depends on what shape the construct an otherwise-producible attribute sits
on takes, not on the attribute in isolation:
- A rule reached only through an
AffixTemplateSlotis necessarily inflectional, andLoadInflAffixProcessRulenever setsMorphologicalOutput's MPR features on an inflectional rule — so a template-slot rule that writes MPR features cannot come from FieldWorks even though the same write is producible on a stratum-level (non-templated) rule. requiredMPRFeaturesmust be identical across every subrule of one rule that is not inflectional: a derivational rule gets a single per-MSA list fromFromProdRestrictRCapplied to every subrule alike (HCLoader.cs:1017), and an unclassified or clitic rule gets none, so two subrules of the same such rule with different required MPR features cannot come from FieldWorks even though the attribute itself is ordinarily producible. An inflectional rule (anIMoInflAffMsa, which includes every template-slot rule) adds the same kind of per-MSA list (HCLoader.cs:1050) and also each allomorph's own inflection classes (HCLoader.cs:1118,:1145,:1172), so its subrules may differ.- A
MorphemeCoOccurrenceRule/AllomorphCoOccurrenceRulemust betype="exclude": HCLoader's two co-occurrence loaders buildExcludeconstraints only, sourced from LibLCM repositories that are prohibition-only by name, sotype="require"has no FieldWorks equivalent regardless of how ordinary the surrounding rule is. MorphologicalRuleOrdermust not matter to a fixture's committed ground truth: HCLoader hardcodes everyStratumthat can hold a rule toUnordered(HCLoader.cs:240,243,387; theSurfacestratum at 246 islinearbut empty) and can never emitlinearfor one that holds a rule. The check is NOT "the fixture's grammar.xml declareslinear" —HermitCrabInput.dtd:244defaultsmorphologicalRuleOrdertolinear, so an absent-or-explicitlineardeclaration is the ordinary case for most fixtures in this suite and flags nothing on its own; testing on the declaration alone would condemn fixtures whose ground truth never actually depends on the ordering. Establish it the way it was originally found: copy the fixture, flip itsStratumtounordered, and re-run the C# founding oracle. Only if some word's found-analysis set changes — a spurious extra parse, a lost one, or a duplicate, never merely a different search order or timing — does the fixture's ground truth actually depend on the ordering, and only then is it HC-engine-only for this reason. What a fixture may declare is narrower than what it may depend on. The order is read for the rules aStratumlists in its ownmorphologicalRulesand when the stratum applies its affix templates (SynthesisStratumRuleandAnalysisStratumRulebranch on it inApplyTemplates; in synthesis anunorderedstratum feeds a template's output back through the rules, which can reach a further template, and alinearone applies it once). Alinearstratum that lists no morphological rule and holds no activeAffixTemplateis producible as written; a stratum that has either must sayunordered, which is how HCLoader builds it. sillsdev/machine'sFieldWorksProducibleShapeTestsenforces this.Environments(RequiredEnvironments/ExcludedEnvironments) is producible only where HCLoader writes one, and only as a required environment. It writes aRootAllomorph's own environment (both sides,LoadRootAllomorph); one environment on a circumfix, whose output is a prefix piece, the stem and a suffix piece (LoadCircumfixAffixProcessAllomorph, left from the prefix's context and right from the suffix's); a right-side environment on a lone suffix; and a left-side environment on a lone prefix (LoadFormAffixProcessAllomorph). The other side of a lone prefix or suffix becomes part of its input pattern, not an environment. It never writes anExcludedEnvironments, an environment on an infix or a reduplication, or one on a multi-action process rule (LoadAffixProcessAllomorphnever touches.Environments). A subrule shaped like the circumfix,InsertSegments+CopyFromInput+InsertSegmentswith one required environment, is therefore producible; HC checks the same environment at each piece of that output.
Three more shapes are checked mechanically because they are plain attribute facts: a rule reached
through a Slot must not have an outputPartOfSpeech (LoadInflAffixProcessRule never sets one); a
partial stem entry must have no part of speech (HCLoader.cs:757, :817); and a partial rule must be
in no slot (:1031) and have no outputPartOfSpeech. sillsdev/machine's FieldWorksProducibleShapeTests fails on a
true fixture that brings any of them back.
These are rows of a wider census, fieldworks-writable-shapes.tsv. For each kind of rule, template,
slot, stratum, stem name, entry and allomorph it lists every DTD attribute and element and says whether
HCLoader.cs writes it (writes), only carries it as a label (metadata), never writes it (never),
filters it out (omitted), or writes it where the engine reads nothing (inert); each row names the loader
line. a sillsdev/machine test (FieldWorksProducibleShapeTests) fails a true fixture that uses a never member or one the table does
not list, and checks what a row cannot say: an empty stem-name region, a required environment on any output
shape but a lone prefix, a lone suffix or a circumfix, a subrule RequiredHeadFeatures on a circumfix or an
affix process, a cap on an exocompound, and MPR features outside the
three loader groups. A new construct goes into the table, with its loader line, before a true fixture may
use it.
Because the verdict is authored, not derived, any mechanical checker built against it may only
prove a true verdict wrong — by finding a construct in a true fixture's grammar.xml that
matches a fieldworks-producibility.tsv "No" row or one of the mechanical checks above — never prove a
false verdict wrong. A checker cannot see the absence of an HCLoader code path; it can only see
that a construct is used, and that is sufficient to falsify true but never sufficient to certify
it, since the research behind a false verdict is exactly the part no checker can redo by reading
grammar.xml alone.
Section 9's fieldworks_producible: true is a claim about grammar.xml shape; it does not itself
prove a real FieldWorks project can be authored and round-tripped to that shape. A fixture MAY carry
a fieldworks/ directory alongside grammar.xml/words.yaml, holding the actual LibLCM project the
claim is about:
fieldworks/project.fwdata— a real FieldWorks project, backed out of the fixture's owngrammar.xmlvia LibLCM (not hand-edited), andfieldworks/WritingSystemStore/*.ldml— the writing-system definitions that project needs to open. The.ldmlfiles hold only the elements that define the writing system (identity,layout,characters,delimiters,collations,special); the CLDR locale data LibLCM copies in (dates, numbers, units, display names) is removed because nothing reads it, and a project with the reduced files opens and projects to the same HermitCrab input as the full ones.fieldworks/phonology-mutations.yaml— a small, versioned manifest of scripted phoneme-inventory counterfactuals against that same project, for a consumer that wants to probe what a narrower or wider phoneme inventory does to the project's own FieldWorks-side projections without hand-editing a project itself.
Only a fieldworks_producible: true fixture is eligible to carry a fieldworks/ directory — a
false fixture has no real project behind it by construction, so there is nothing for one to
contain.
This repository never opens, parses, or executes anything under fieldworks/: .fwdata is a
FieldWorks/LibLCM format, and nothing in this suite's adapter protocol reads it. It exists for a consumer that already speaks that format (today, PanGloss's xample-projector tool, which stays in PanGloss) to read directly. What this repository DOES enforce is the
directory's shape — see scripts/check_fwdata.py — and, since project.fwdata is opaque here,
that enforcement is necessarily structural rather than semantic: file names, a schema version, and a
hash, not a check that the project actually opens or actually contains what the manifest claims.
A consumer must verify phonology-mutations.yaml's base_sha256 against the actual SHA-256 of
fieldworks/project.fwdata before trusting anything else in the manifest — the two files can drift
independently (a fixture updated in place, a manifest hand-edited, a partial checkout), and a mutation
op naming a guid that isn't even in the project it's about to run against is a worse failure mode
than a mismatched hash caught up front.
phonology-mutations.yaml's v1 vocabulary is exactly:
- Two operations:
remove_phoneme(guid,assert_representations,require_unreferenced) andremove_all_phonemes(require_unreferenced) — see PanGloss'sxample-projectormutatecommand for what each does to a cloned copy of the project. - Two
expectfields whose only defined value is the literal stringsame_as_base:xample_projectionandhc_analyses.same_as_basemeans equal up to LibLCM's own hvo renumbering (every M3-dumpId/dstattribute is a raw, per-load-session object handle —bMorphnameIsMsaIdin FieldWorks' own XSL transforms — so two projections of two different LibLCM sessions are never byte-identical even when nothing meaningful changed), never byte-equality. expect.inferred_segments— the phoneme representations a consumer should expect to no longer be producible after the case's operations run.
A manifest field outside this vocabulary, or an expect.xample_projection/expect.hc_analyses value
other than same_as_base, is not yet defined — a consumer encountering one should refuse rather than
guess at a meaning this repository has not specified.