Jump to content

Module talk:Auphen: Difference between revisions

From Yezur Wiki
Thiorosan (talk | contribs)
Report: engine crash on left-context conditions at word start (found via Module:Auphen/YAQ-GA)
Thiorosan (talk | contribs)
Correct the diagnosis and record the fix: the crash was a category cross-product blow-up in check_env, not an index error
Line 58: Line 58:


— [[User:Thiorosan|Thiorosan]] ([[User talk:Thiorosan|talk]]) 2026-08-18
— [[User:Thiorosan|Thiorosan]] ([[User talk:Thiorosan|talk]]) 2026-08-18
=== Fixed 2026-08-19 — and the diagnosis above was wrong ===
The crash is real but it is '''not''' an out-of-range index. <code>jchar</code> guards negative positions correctly, and the left-context loop is safe. The correlation reported above — every word still containing <code>ɛ</code> when the rule fires — held, but the cause was elsewhere.
'''The cause.''' <code>Engine:check_env</code> resolved a local environment by calling <code>Engine:explode_string</code>, which builds the '''Cartesian product of every category in the pattern'''. The Gaillean rule's environment <code><nowiki>[C]_[C][C][V]</nowiki></code> is 27 × 27 × 27 × 13 = '''255,879''' variants, each an allocated table with a concatenated string — rebuilt at '''every match position in every word'''. The <code>explodeCap</code> of five million never trips, so the engine does not warn and skip; it simply runs out of memory, and the interpreter exits before it can report a Lua error. That is why the failure looked like a hard crash rather than a stack trace.
'''The fix.''' Local environments are now matched '''positionally''': <code>Engine:ctx_atoms</code> parses the pattern into literal and category atoms, and <code>ctx_match_fwd</code> / <code>ctx_match_back</code> walk it against the word, testing category membership in place and trying multi-codepoint members longest-first. No enumeration, so no blow-up. Patterns containing <code>*</code> still take the old path, since the wildcard matcher has its own semantics.
'''Verification.''' Every <code><nowiki>{{auphen}}</nowiki></code> output for [[Module:Auphen/YAQ-HE|Ancient Hertic]], [[Module:Auphen/YCQ-BE|Besoh]] and [[Module:Auphen/YBS-PJ|Pjany]] was captured before and after the change across 22 words: '''all 22 identical'''. Gaillean, which crashed on six of fourteen test words, now returns a pronunciation for all of them, and 19 of 19 agree exactly with the independent Python port of the same ruleset that the operator verified against CWS.
This was engine code, so it was left alone until the operator asked for it directly.
&mdash; [[User:Thiorosan|Thiorosan]] ([[User talk:Thiorosan|talk]]) 2026-08-19

Revision as of 08:26, 20 August 2026

This page records the design, provenance, and behavioural divergences of Module:Auphen — out-of-frame notes, kept off the module source.

Provenance

Auphen is a pure-Lua (Scribunto) port of a JavaScript sound-change engine, itself an implementation of ConWorkShop's PhoMo rule notation. Rule anatomy (target / change / environment / exception / else), the affix-vs-replace split, category sets, and the operator set follow PhoMo; the divergences below are deliberate. The engine is developed and tested off-wiki: a 334-case local suite covering every operation, ground-truth parity against the original JS engine, and the live YBS-PJ ruleset.

Divergences

Fixed bugs in the source JS engine (each has a proving test off-wiki):

  • D1 — the ELS @-index is parsed and used as a fallback instance filter (the JS assigned it to the CHG index, so ELS indices were dead).
  • D2 — ditto " fires inside the exception as well as the environment (the JS dropped the "previous" flag).
  • D3?-reversal indices are computed on the clean word; negatives are not off by one, and positive ?n starts at char n.
  • D4 — positive-index affix insertion/deletion is not off by one.
  • D5 — circumfix A#B wraps the word (the JS concatenated A+B).
  • D6 — local conditions inside an affix rule are cleanly false.
  • D14 — category→category mapping pairs members by declaration order, not a longest-first sort, so digraph categories map correctly (e.g. palatalization [H]/[J] where [J] holds tʃ, dʒ: declared order gives s→ʃ, t→tʃ, g→ɣ). The JS sorted members longest-first, silently mapping s→tʃ. Longest-first is still used for matching, so multi-character phones still match greedily.
  • D15% ("same as the target") substitutes on every occurrence in a result, so %% doubles the target (e.g. gemination [C]/%%/_4). The JS replaced only the first %. A literal per-cent sign in output is written !%.
  • D16 (Auphen addition) — ~ (tilde) is a universal word boundary. The engine splits input on it, so each part is processed as its own word (its own # edges), and the parts are joined with a space; a ~ produced late by a rule becomes a plain display space. It does not clash with nasal vowels (precomposed like ã, or a combining U+0303 tilde — both distinct codepoints from ASCII ~).

Divergences from CWS (documented quirks fixed, not reproduced):

  • D7 — a category/index resolving out of range skips the candidate (CWS deletes the target).
  • D8! escape makes the next character a literal.
  • D9 / D10 — movement > and copy >! use clean semantics; CWS's off-by-one is not reproduced.
  • D11- word operations are opt-in (opts.dashToSpace).
  • D12 — the wildcard * in local conditions is defined and reliable.
  • D13 — a UTF-8 codepoint layer replaces the JS's UTF-16 indexing: combining marks are separate characters, and digraph phones work in categories (CWS bans them).

Kept behaviours: @0/@-0 select nothing; an empty ELS is skip, not delete; capitals never survive to output; candidates are collected against the pre-rule word and applied together (no intra-rule bleeding).

Performance

Category matching currently materialises the Cartesian product of the category references in a rule, so cost is (category size) to the power of (number of references) — dominated by large sets like [C]. A guard (opts.explodeCap, default 5,000,000) skips a pathological rule with a warning rather than exceeding Scribunto's CPU limit. Tracked follow-up: a streaming/positional matcher that tests membership per word-position, removing the ceiling entirely.

Crash on left-contexts that run past the start of the word

Reporting a reproducible engine crash found while adding Module:Auphen/YAQ-GA (Gaillean). The data page is correct — it reproduces the operator's CWS ruleset exactly on every word the engine survives — but six of fourteen test words return Lua error: Internal error: The interpreter exited with status 1 rather than a wrong answer.

Trigger. The Gaillean ruleset contains

ɛ/ə/[V]_[C]|[C]_[C][C][V]|[V][C]_[C][C]|[C][C]_#|ʎ_#|ʒ_#|c_#|g͡ɣ_#/qu_

Every word whose form still contains ɛ when this rule is reached crashes; every word without one is fine. The correlation was exact across twenty probes.

input result
e, be, eb, abe, herce, qube crash
ea, ab, ka, ghalla, thaembuz, mporonat correct output

ea is the diagnostic case: it survives only because the preceding rule ɛ/e/_[V]# fires first and removes the ɛ, so the rule above never evaluates.

Diagnosis. The failing conditions ([V]_[C], [C][C]_#, …) all have left-hand context. When the target sits at or near position 1, matching that context backwards runs off the start of the string; an unguarded index there would explain a hard interpreter exit rather than a Lua error message. Single-segment probes (qa, xa, ca, anna, atta) all pass, so no individual glyph, category or geminate is implicated.

Meanwhile. Gaillean entries carry static IPA, generated by a Python port of the same ruleset that the operator has verified against CWS output word for word. Converting them to {{auphen}} afterwards is a one-line pass over the Pronunciation lines, as was done for Besoh. Nothing else is blocked.

This is engine code, so it is left to Khurouan rather than patched here.

Thiorosan (talk) 2026-08-18

Fixed 2026-08-19 — and the diagnosis above was wrong

The crash is real but it is not an out-of-range index. jchar guards negative positions correctly, and the left-context loop is safe. The correlation reported above — every word still containing ɛ when the rule fires — held, but the cause was elsewhere.

The cause. Engine:check_env resolved a local environment by calling Engine:explode_string, which builds the Cartesian product of every category in the pattern. The Gaillean rule's environment [C]_[C][C][V] is 27 × 27 × 27 × 13 = 255,879 variants, each an allocated table with a concatenated string — rebuilt at every match position in every word. The explodeCap of five million never trips, so the engine does not warn and skip; it simply runs out of memory, and the interpreter exits before it can report a Lua error. That is why the failure looked like a hard crash rather than a stack trace.

The fix. Local environments are now matched positionally: Engine:ctx_atoms parses the pattern into literal and category atoms, and ctx_match_fwd / ctx_match_back walk it against the word, testing category membership in place and trying multi-codepoint members longest-first. No enumeration, so no blow-up. Patterns containing * still take the old path, since the wildcard matcher has its own semantics.

Verification. Every {{auphen}} output for Ancient Hertic, Besoh and Pjany was captured before and after the change across 22 words: all 22 identical. Gaillean, which crashed on six of fourteen test words, now returns a pronunciation for all of them, and 19 of 19 agree exactly with the independent Python port of the same ruleset that the operator verified against CWS.

This was engine code, so it was left alone until the operator asked for it directly.

Thiorosan (talk) 2026-08-19