# Perkins `--narrow` — Chilean Spanish Narrow-Transcription Ruleset

## Purpose

`--narrow` (alias `-nt`) applies a set of **phonologically-conditioned**
narrow-transcription rules for Chilean Spanish on top of whatever
phonemic output Perkins' current transcription mode already produces.
It is a standalone flag, independent of output mode — it works the same
way whether combined with `-mfa`, plain running-text phonemic
transcription, `-phonword`, `-vrt`, or any other mode.

The ruleset covers **only** phenomena that are fully determined by
phonological context — give the rule the same sound in the same
environment, and it always produces the same answer, with no reference
to audio required. This is a deliberate, principled boundary:

- **Categorical, rule-based phenomena** (this document) — implemented
  directly in Perkins via `--narrow`, since text alone determines the
  outcome.
- **Free-variation phenomena** (fricative-vs-approximant spirantization
  quality, /d̪/-elision, /θ/ as a sociolinguistic allophone of /s/) —
  deliberately **excluded** from `--narrow`. These cannot be determined
  from text; they require either an MFA `rules.yaml` optional-
  pronunciation entry (letting the forced aligner pick whichever
  candidate the real audio supports) or a separate phone recognizer
  fine-tuned for allophone identification. See "What's deliberately NOT
  here" below.

## Usage

```
perkins -i=input.txt -o=output.txt --narrow
perkins -i=input.txt -o=output.G2P -mfa -nt
```

Can be combined with any other flag or metaconfiguration. Also settable
via `perkins.ini` (`narrow_transcription = 1`).

## Where it runs in the pipeline

`narrow_transcription()` is called during the output-file cleanup
pass, right after `word_level_syllab_adjust()` (the existing cross-word
resyllabification routine) and independently of
`$syllabify_by_sentence` — it runs whether or not sentence-level
syllabification is on.

**This matters for how the rules are written.** By the time
`--narrow` runs, per-word stress-marking and (if active) cross-word
resyllabification have already happened — so the string it receives
already has syllable dots (`.`) and stress marks (`ˈ`/`ˌ`) sitting
between phonemes, not just plain word-boundary spaces. Every rule below
uses a general separator class —

```perl
my $SEP = "[. ˈˌ]*";
```

— matching any combination of dot/space/stress-mark (zero or more)
between two phonemes, rather than a bare optional space. This was
**not** the original design — the first implementation assumed plain
spaces only, and testing against real Perkins output (not just a
synthetic string) is what surfaced the gap. A real pause (`|`/`‖`)
still blocks every rule, since a pause character sitting between two
segments simply isn't part of the separator class and breaks the
match — no separate boundary-detection logic is needed.

## The rules, in order

Rule order matters throughout: several rules are stated as exceptions
to, or depend on the output of, earlier ones.

### 1. `nb` → `ɱv` (atomic)

```perl
$s =~ s/n($SEP)b/ɱ$1v/g;
```

Runs **first**, before generic `b`-spirantization (rule 2) — it is an
*exception* to the general pattern, not a consequence of it. The
labiodental nasal `ɱ` here reflects the nasal assimilating to `b`'s own
already-spirantizing surface place (labiodental), not to `b`'s
underlying bilabial place. Crosses a word boundary (e.g. "un beso" →
`ũɱ.ˈve.so`), but not a pause.

Confirmed this is **Chilean-specific**, not standard descriptive
Spanish phonology, which would normally have `b` surface as a stop
after any nasal (nasal-triggered *blocking* of spirantization). This
was checked and confirmed directly, twice, since it's the kind of
assumption that's been wrong before.

### 2. `b` between vowels → `ʋ`

```perl
$s =~ s/($V)($SEP)b($SEP)($V)/$1$2ʋ$3$4/g;
```

Crosses word boundaries (e.g. "la bota" → `la.ˈʋo.t̪a`). `$V` is the
bare vowel alternation `a|e|i|o|u|á|é|í|ó|ú|i̯|u̯` — note this
deliberately does **not** include the glide symbols `j`/`w` themselves,
only their non-syllabic-vowel-diacritic forms. A `b` before a glide
(e.g. "lluvia" → `bja`) is therefore correctly left as a stop, since
the following segment isn't in `$V`.

### 3. `b` word-finally → `v`

```perl
$s =~ s/b( |\.|\||‖|$)/v$1/g;
```

Runs after rule 2 has already consumed every intervocalic `b`, so any
`b` still literally present at this point, immediately before a
word/syllable boundary, pause, or end of string, is genuinely
word-final. Plain `v` — **not** `ʋ` — symmetric with rule 7's plain
`ɣ`, confirmed intentional, not a typo.

### 4. `d̪`/`d` between vowels → `ð̞`

```perl
$s =~ s/($V)($SEP)(d̪|d)($SEP)($V)/$1$2ð̞$4$5/g;
```

Crosses word boundaries (e.g. "trata de" → `t̪ɾa.t̪a.ð̞e`). Matches both
`d̪` (dental-diacritic form, Perkins' default) and bare `d`, in case the
dental diacritic is ever disabled.

**"After `l`" is excluded by construction, not by a separate rule.**
Being preceded by `l` (a consonant) is incompatible with being preceded
by a vowel in the first place — the two conditions can never both be
true for the same segment, so rule 4's own "between vowels" trigger
already can never fire for a post-`l` `d̪` — no lookbehind needed here
at all. (Contrast this with rule 5, where the exception genuinely does
need an explicit lookbehind — see below.)

### 5. `d̪`/`d` word-finally → `ð̞`, except immediately after `l`

```perl
$s =~ s/(?<!l)(?<!l )(?<!l\.)(d̪|d)( |\.|\||‖|$)/ð̞$2/g;
```

Unlike rule 4, a word-final `d̪` genuinely *can* be immediately preceded
by `l` (e.g. a hypothetical word ending in `-ld̪`) — so this rule needs
an explicit negative lookbehind, checked against `l` directly adjacent,
`l` + space, and `l` + syllable dot.

**Left as `d̪` in this environment — deliberately not resolved.** This
case is genuinely indeterminate: real Chilean speech often elides `d̪`
here entirely, but there's no reliable way to determine from text alone
whether a given token would be elided, kept as `d̪`, or lenited to `ð̞`.
Rather than guess, the rule leaves `d̪` untouched, matching the same
principle that keeps outright elision out of `--narrow` entirely (see
"What's deliberately NOT here").

### 6. `ɡ` between vowels → `ɣ̞`

```perl
$s =~ s/($V)($SEP)ɡ($SEP)($V)/$1$2ɣ̞$3$4/g;
```

Crosses word boundaries. Uses `ɣ̞` — `ɣ` (U+0263) plus the **lowered**
combining diacritic (U+031E) — marking a more open, approximant-like
realization than the plain fricative. Confirmed this exact codepoint
combination directly (`ɣ̞` = U+0263 + U+031E) before using it anywhere.

### 7. `ɡ` word-finally → `ɣ`

```perl
$s =~ s/ɡ( |\.|\||‖|$)/ɣ$1/g;
```

Plain `ɣ` — **no** lowered diacritic here, unlike rule 6's `ɣ̞` —
symmetric with rule 3's plain `v`. Confirmed intentional, not an
inconsistency: word-final position gets the plain fricative for both
`b`→`v` and `ɡ`→`ɣ`, while intervocalic position gets the more lenited
forms (`ʋ`, `ɣ̞`) for `b` and `ɡ` respectively (and `ð̞` for `d̪`, which
gets the lowered form in *both* environments — see rules 4 and 5 above;
this asymmetry across the three places of articulation was explicitly
confirmed, not assumed).

### 8. Yeísmo: `ɟʝ` → `dʒ` utterance-initially, `ʝ` everywhere else

```perl
$s =~ s/(^|‖|\|)($SEP)ɟʝ/$1$2dʒ/g;
$s =~ s/ɟʝ/ʝ/g;
```

"Utterance-initial" means the very start of the string, or immediately
after a pause marker (`|`/`‖`) — matching the fact that this is a
positional-strengthening pattern (affrication of `/ʝ/` at a genuine
utterance boundary), not sociolinguistic free variation, which is why
it qualifies for `--narrow` at all. The second substitution catches
whatever's left — i.e., every non-utterance-initial occurrence — and
downgrades it to the plain fricative.

### 9. Velar palatalization before /i/ or /e/ (v2.0.12)

```perl
my $FRONT_V = "i|e|í|é";
$s =~ s/k(?=$SEP(?:$FRONT_V))/c/g;
$s =~ s/x(?=$SEP(?:$FRONT_V))/ç/g;
$s =~ s/ɡ(?=$SEP(?:$FRONT_V))/ɟ/g;
$s =~ s/ɣ(?=$SEP(?:$FRONT_V))/ʝ/g;
$s =~ s/ɣ̞(?=$SEP(?:$FRONT_V))/ʝ̞/g;
```

Runs **after** spirantization (rules 4–7), so it correctly sees
whichever surface form those rules already produced for `/ɡ/`
specifically: `ɟ` only catches a `ɡ` that's *still a stop* (utterance-
initial or after a nasal) — an intervocalic or word-final `ɡ` before a
front vowel has already become `ɣ̞` or `ɣ` by the time this rule runs,
which is why this rule also separately covers `ɣ`→`ʝ` and `ɣ̞`→`ʝ̞`, not
just `ɡ`→`ɟ`. Runs **before** nasal place assimilation (renumbered rule
10, below), so a nasal preceding a newly-palatalized consonant
correctly assimilates to palatal place, not the velar place the
consonant no longer has by the time rule 10 would otherwise see it.

**`ɣ̞`→`ʝ̞`, not a bare `j` glide, deliberately.** A bare `j` was briefly
considered and rejected during design, specifically because Perkins
already uses plain `j` as its default representation for the ordinary
rising-diphthong glide `/j/` (e.g. "unión", "hielo") — completely
unrelated to this rule. Had this stayed as `j`, rule 10's expanded
palatal list (below) would have also wrongly triggered nasal
palatalization before every ordinary glide `j` already in the
language, not just the newly-palatalized velar-derived one. Using the
distinct `ʝ̞` (`ʝ` plus the same lowering diacritic already used in
`ɣ̞`) avoids this collision entirely.

Verified directly, individually and in combination: "queso" → `ce.so`
(`k`→`c`); "kilo" → `ci.lo`; "jinete" → `çi.ne.t̪e` (`x`→`ç`, with
correct incidental vowel nasalization from the following `/n/`); "un
guiso" → `uɲɟi.so` (`ɡ`→`ɟ` in blocked/stop context, plus rule 10's
nasal palatalization below); "seguir" → `se.ʝ̞iɾ` and "aguila" →
`a.ʝ̞i.la` (intervocalic `ɡ`→`ɣ̞`→`ʝ̞`); "tengo" → `t̪ẽŋ.ɡo` (`ɡ`
correctly *not* palatalized before "o", a non-front vowel).

### 10. Nasal place assimilation (`/n/` only — `/m/` never changes on its own)

```perl
$s =~ s/n($SEP)(tʃ|ʝ|ɟʝ|ɲ|c|ç|ɟ)/ɲ$1$2/g;   # palatal -- MUST run before the dental rule below
$s =~ s/n($SEP)p/m$1p/g;
$s =~ s/n($SEP)f/ɱ$1f/g;
$s =~ s/n($SEP)(t̪|d̪|t|d)/n̪$1$2/g;
$s =~ s/n($SEP)(k|ɡ|x)/ŋ$1$2/g;
# n before s/l/ɾ/r -> n, i.e. no actual change: nothing to do.
```

Full correspondence table:

| Following consonant | `/n/` → | Consonant itself |
|---|---|---|
| `p` | `m` | unchanged |
| `b` | `ɱ` | → `v` (rule 1's special atomic case, handled there, not here) |
| `f` | `ɱ` | unchanged |
| `t̪`, `d̪`, `t`, `d` | `n̪` | unchanged |
| `s`, `l`, `ɾ`, `r` | `n` | unchanged (no real change) |
| `tʃ`, `ʝ`, `ɟʝ`, `ɲ`, `c`, `ç`, `ɟ` | `ɲ` | unchanged |
| `k`, `ɡ`, `x` | `ŋ` | unchanged (and `ɡ` here stays a stop, per rule 6 — `/n/` isn't a vowel, so the intervocalic lenition condition never applies here) |

**v2.0.12: expanded to include the new palatalized velars** (`c`, `ç`,
`ɟ`) alongside the original `tʃ`/`ʝ`/`ɟʝ`/`ɲ` set — note `j`/`ʝ̞` are
*not* in this list (see rule 9's collision note above for why `ʝ̞` was
chosen specifically to avoid needing to add anything here at all: the
existing bare `ʝ` alternative already matches `ʝ̞`'s leading character,
since the substitution only consumes `n`+separator and leaves the
matched consonant capture group untouched, diacritic and all).
Verified directly: "un guiso" → `uɲɟi.so` (`/n/`→`ɲ` before the newly-
palatalized `ɟ`, not the `ŋ` it would otherwise get before a plain
velar); "unión" → `ũ.njõn` (ordinary glide `j`, unaffected — confirming
the collision described in rule 9 is genuinely avoided, not just
theoretically).

**Real ordering bug found and fixed during testing**: the palatal rule
(`tʃ`/`ʝ`/`ɟʝ`/`ɲ`) must run *before* the dental rule (`t̪`/`d̪`/`t`/`d`).
`tʃ` starts with a literal `t`, so if the dental rule ran first, its
bare `t` alternative would greedily consume the `t` of `tʃ`, and the
palatal rule would never get a chance to see the full digraph. Caught
directly: `"un chico"` was producing `ũn̪ tʃiko` (wrong — dental)
instead of the correct `ũɲ tʃiko` (palatal), traced to exactly this,
and fixed by reordering.

`/m/` is deliberately excluded from all of this — it never changes on
its own before any following consonant (only the atomic `nb`→`ɱv`
exception in rule 1 touches it, and that's really about `/n/`
assimilating to match `b`'s own surface place, with the two segments
treated as one unit).

### 11. Vowel nasalization

```perl
$s =~ s/($N)($SEP)($V)(?!\x{0303})/$1$2$3\x{0303}/g;
$s =~ s/($V)(?!\x{0303})($SEP)($N)/$1\x{0303}$2$3/g;
```

Any vowel in **direct, immediate, one-segment** contact with a nasal —
before or after, crossing a word boundary but never a pause — gets a
combining tilde (U+0303). "One segment, no intervening segments
allowed, ever" — this is strictly V-N/N-V adjacency, never V-C-N or
similar.

Uses the nasal's **surface** form (`m|ɱ|n̪|ɲ|ŋ|n`) — i.e., *after* rule
9's place assimilation has already run, not the underlying `/n/`/`/m/`
phoneme. Confirmed directly: nasalization is sensitive to whatever the
nasal actually surfaces as, not its underlying identity.

**Idempotency**: each pass's negative lookahead (`(?!\x{0303})`)
prevents ever adding a *second* tilde to a vowel that the *other* pass
already nasalized — this matters for a vowel sitting directly between
two nasals (e.g. "nan" → `nãn`, not a malformed double-tilde). Tested
explicitly and confirmed correct.

**Stress-mark interaction**: since stress in most output modes is
marked at the syllable boundary (before the onset), not stacked onto
the vowel itself, there's normally no conflict between the stress mark
and the nasalization tilde. In modes where stress *is* shown as a tilde
over the vowel (`-st`), the nasalization tilde is added to the same,
already-accented vowel (e.g. `á` → `á̃`) — there is no dedicated
handling needed beyond what the general separator class and the
existing accented-vowel entries in `$V` already provide.

## What's deliberately NOT here

Three phenomena were identified during the design discussion and
explicitly excluded from `--narrow`, each for a different, specific
reason:

- **Fricative-vs-approximant spirantization quality** (whether a
  lenited `b`/`d̪`/`ɡ` surfaces as a fricative or an approximant) — this
  is free variation, not predictable from context even in principle.
  Left to the phone recognizer, for the `phones-allo` tier.
- **`d̪`-elision** (`-ado`/`-ido`, word-final) — explicitly described as
  "may be elided," i.e. optional, not categorical. This is exactly the
  kind of thing MFA's `rules.yaml` is built for: offering multiple
  candidate pronunciations and letting the forced aligner pick whichever
  one the actual audio supports for that specific utterance, rather than
  Perkins guessing at a single answer from text alone.
- **`/θ/` as a sociolinguistic allophone of `/s/`** — some Chilean
  speakers have this variably; it's sociolinguistically, not
  phonologically, conditioned. Perkins never generates it. It's noted
  here only as a **whitelist reminder**: if the phone recognizer's own
  output includes `/θ/`, don't discard it as noise.

## Verification

Every rule was tested individually and in combination, first against a
standalone Perl harness, then — after finding that the harness's plain-
space assumption didn't match real Perkins output — against the actual
compiled pipeline, using real Perkins output as input rather than
hand-constructed strings. Confirmed cases include:

- `"un beso"` → `ũɱ.ˈve.so` (rule 1 + rule 11)
- `"la bota"` → `la.ˈʋo.t̪a` (rule 2, crossing a word boundary)
- `"el chico anda"` → `el.ˈt͡ʃi.ko.ˈãn̪.d̪a` (rule 10's palatal-before-dental
  ordering fix; `d̪` in "anda" correctly stays a stop since it's not
  intervocalic)
- A pause marker correctly blocks rule 1 from crossing it (confirmed
  with a real `‖` present in the string)
- The full standard usage-guide sentence ("En Concepción, se trata de
  aguantar la lluvia durante 5 meses del año. ¿Cachái?") → `ẽŋ.kõn.sep.
  ˈsjõn.se.ˈt̪ɾa.t̪a.ð̞e.a.ɡwãn̪.ˈt̪aɾ.la.ˈʝu.bja.ð̞u.ˈɾãn̪.t̪e.ˈsĩŋ.kõ.ˈmẽ.
  ses.d̪e.ˈlã.ɲõ.ka.ˈt͡ʃaj` — exercising nearly every rule at once,
  including the "after a consonant, no change" case (`"del"` → `d̪e`,
  since the preceding `s` in `"meses"` isn't a vowel) and nasalization
  from all three surface nasal places (`ŋ`, `n̪`, `ɲ`) in the same
  sentence.

## Known open item (not part of `--narrow` itself)

While testing, a separate, pre-existing question surfaced: whether an
ordinary sentence-ending period reliably produces a visible pause
marker (`|`/`‖`) in Perkins' own output under normal settings. A quick
test didn't show one where expected. This is unrelated to `--narrow` —
`--narrow`'s own rules correctly respect a pause marker whenever one is
actually present in the string, confirmed directly — but it's worth
tracking down separately, since it affects whether the "blocked at an
utterance boundary" behavior described throughout this document is
reliably reachable from ordinary punctuated input, or only from text
that already contains an explicit `|`/`‖`.

## `-g2p`/`-phonword` interaction — a real bug found and fixed (v2.0.11)

`-g2p` and `-phonword` both write their "already in final form" output
directly during the first processing pass and then `exit` before the
Stage-2 cleanup pass — which is where `narrow_transcription()` normally
runs. **`--narrow` silently had no effect at all when combined with
either mode** until this was found and fixed directly.

Fixing this surfaced two further, genuinely separate problems, each
found by testing the fix rather than assumed correct:

1. **Cross-word rules need the whole line, not one word at a time.**
   Applying `narrow_transcription()` to each word's phoneme string
   separately, before joining, meant a cross-word rule like `nb → ɱv`
   could never see the boundary between two words — confirmed directly
   ("un beso" produced two independently-narrowed fragments, never the
   correct combined result). Fixed for `-g2p`'s one-word-per-line mode
   by deferring `narrow_transcription()` until *after* a line's words
   are joined with real spaces, mirroring how the Stage-2 pass already
   works.

   **`-g2p`'s running-text mode is a genuine, permanent exception, not
   a further bug**: each word there becomes its own independent,
   reusable dictionary entry, with no next-word context available at
   all when the entry is built — so cross-word rules (rule 1's `nb`,
   and cross-word spirantization/nasalization) simply cannot apply in
   this mode, by design. Only the rules that don't need cross-word
   context still take effect there.

2. **`-g2p`'s own output convention conflicts with the rules'
   separator assumptions.** When `$space_between_phonemes` is on
   (`-g2p`'s default), it puts a literal space between *every* phoneme,
   not just between words — indistinguishable, to the rules' own
   separator class, from a genuine word-boundary space. Confirmed
   directly: an isolated word, `"beso"` (phonemes `"b e s o"`), had its
   word-*initial* `b` wrongly treated as word-*final* and turned into
   `v`, since rule 3's word-final pattern matches `b` followed by any
   space, and `-g2p`'s own spacing put one right there. Fixed with a
   `\x01` placeholder: each word's internal `-g2p` phoneme-spacing is
   converted to `\x01` before `narrow_transcription()` ever sees it (so
   it's correctly ignored as a within-word boundary, exactly like a
   period or stress mark already was), while genuine word-boundary
   spaces are left as real spaces so cross-word rules still fire
   correctly. `\x01` is converted back to a plain space afterward.
   `$SEP` now includes `\x01` alongside its other separator characters.

All three fixes verified together: `"un beso"` via
`-g2p --g2p-wl --narrow` → `unbeso\tũ ɱ v e s o` (cross-word rule
correctly fires); an isolated `"beso"` via the same flags → `b e s o`
(no longer wrongly narrowed); `-g2p --g2p-rt --narrow` correctly
narrows each word independently with no cross-word effect (the
documented limitation above, not a bug); `-phonword --narrow`, default
mode with `--narrow`, and `-g2p` without `--narrow` all confirmed
unaffected by any of this (regression-checked).

## Utterance-boundary fix (v2.0.13) — the semicolon bug, actually fixed

The stress-test finding from earlier ("cross-word rules can fire across
clause punctuation") is now fixed, not just documented. Root cause was
precise: `normalize_structured_tokens()` silently discarded utterance-
boundary punctuation (`.`, `...`, `!`, `?`, `:`, `;`) at the line level,
before word-splitting — long before any pause-detection code got a
chance to see it. Fixed by replacing that punctuation with a bracketed
sentinel (`[UtteranceBoundary]`, matching the existing
`[MalformedNumber]`-style convention) instead of discarding it. The
sentinel survives word-splitting and phonemicization untouched, and is
converted to the real `‖` symbol during Stage 2 cleanup — right where
`word_level_syllab_adjust()` and `narrow_transcription()` already
correctly treat `‖` as a hard boundary.

Per direct instruction, the boundary character set also includes em
dash (`—`) and the single-character ellipsis symbol (`…`, distinct from
three literal periods), both handled with a simpler unconditional
substitution since neither is reliably attached to a preceding word.
Comma is deliberately excluded — it remains a separate, shorter pause.

`-g2p` (both submodes) and `-phonword` needed their own explicit skip
for the sentinel, for the same reason `narrow_transcription()` itself
needed one in v2.0.11: both exit before Stage 2. Found as a real leak
during testing — without the skip, `-g2p` tried to phonemicize the
sentinel as an ordinary word, producing garbage.

Verified at full scale: 33 correctly-placed pause markers across the
~1,245-word real Wikipedia stress-test corpus, zero sentinel leaks,
zero regressions to any other mode.

## CV/CVG/CVNLRG/voicing + `--narrow`: now a hard error

Per direct instruction: these four output modes reduce every phoneme
to a single category letter, and `--narrow`'s allophonic distinctions
collapse to the same letter either way, so the combination can never
change the output — it was silently producing plausible-looking output
that didn't actually reflect `--narrow` at all. Combining them now
dies immediately with a clear error message (in both English and
Spanish, matching the rest of Perkins), checked right after all flags
and metaconfigs have been fully resolved, before any file processing
begins.

## Manner and place: made to work correctly with `--narrow`

Unlike CV/CVG/CVNLRG/voicing, manner and place output genuinely can
differ under `--narrow` (a stop vs. a fricative /b/ are different
manners), so these were fixed rather than blocked. Every new `--narrow`
phoneme (ʋ, ð̞, ɣ̞, ɣ, ɟ, ç, ʝ̞, ɱ, c) was added to the relevant
character-class variables. Two real bugs found and fixed in the
process, not just additions:

1. **n̪ (dental nasal) was wrongly classified as alveolar in place
   mode.** `$alveolars`' own bare `n` entry was catching it before
   `$dentals` got a chance to. Fixed by adding `n̪` explicitly to
   `$dentals`, which already correctly runs before `$alveolars`.
2. **ɣ̞/ʝ̞ (the lowered, approximant-quality outputs) share a base
   character with an existing bare entry already in `$fricatives`.**
   Since `$fricatives` ran before `$aproximants` in the manner
   function's existing order, it would wrongly consume just the base
   character as a plain fricative before `$aproximants` ever saw the
   full sequence. Fixed by reordering those two substitutions
   specifically in `opt_convert_phonemes_manners()`. (Place mode
   needed no such reordering — ɣ and ɣ̞ correctly map to the same place
   category either way.)

Verified phoneme-by-phoneme, not spot-checked: every rule-coverage
word ("un beso, un guiso, la bota, aguila, seguir, tengo, queso") now
produces a real, correct category letter for every single phoneme in
both manner and place mode — zero unclassified fallthrough characters
remain.

---

## v2.0.14 — the sentinel was being deleted, not used (`-g2p --g2p-wl`)

Found by direct adversarial testing after v2.0.13 was believed finished,
not by waiting for it to surface.

`-g2p`'s one-word-per-line mode was **silently discarding** the
utterance-boundary sentinel rather than using it to block cross-word
rules. The boundary information reached the mode and was then stripped
before it could do its job, so a boundary inside a headword sequence had
no effect at all.

The exact failing case: `un pan; beso` wrongly produced a nasal
assimilation and spirantization straight across the semicolon
(ũmpãɱveso) instead of stopping at it.

Fixed by pushing a real pause marker **before** `narrow_transcription()`
runs, then stripping it from the final dictionary entry — so the rule
engine sees the boundary while the written entry stays clean. Verified
against the exact bug case and against a headword sequence carrying
multiple boundaries; the full 41-combination regression was re-run
clean.

---

## v2.0.15 — a pre-existing `-vrt` bug, and one deliberately left alone

**Fixed.** `do_vrt_preprocessing()` converted every space to its own
internal marker before word-splitting, which swallowed the spaces around
**any** bracketed sentinel token. Confirmed this predates all `--narrow`
work — the pre-existing `[InvalidDate]` sentinel was equally broken in
`-vrt` mode in the untouched v2.0.8 baseline, so this was not something
`--narrow` introduced. Fixed by excluding only the spaces directly
touching a bracket, rather than treating all spaces alike.

**Found and deliberately NOT fixed**, flagged rather than silently
patched: a separate, deeper `-vrt` architectural quirk. Stage 2's
read/chomp/rewrite cycle does not reinsert newlines or spaces between
what were originally separate output lines, so a pause symbol can end up
touching the word before or after it. Confirmed identical in the
untouched v2.0.8 baseline. **Purely cosmetic — the rules themselves
still fire and block correctly across that join**, which is why it was
left: fixing it is a separate and larger undertaking than it looks, and
doing it quietly inside a `--narrow` release would have buried a real
change to shared code.

---

## Currency

This document is in sync with Perkins through **v2.0.15**. Before
v2.0.14 and v2.0.15 were appended above, it stopped at v2.0.13 while
`HOWTO--Perkins-narrow-transcription.md` §40 claimed it was in sync
through v2.0.15 — that claim was two versions ahead of the file and is
now true rather than aspirational.
