Skip to content

ir: CanonicalWords is not idempotent when lowercasing creates an acronym tail #336

Description

@OmarAlJarrah

ir.CanonicalWords is documented as a fixed point, and
FuzzCanonicalWords_Properties/assertIdempotent asserts it. Feeding a
canonical back in can still change it:

CanonicalWords("ℤℤA") == "ℤℤa"
CanonicalWords("ℤℤa") == "ℤ_ℤa"

"Aℤ" — the shape #187 named — is a fixed point today, so #187's own case is
fixed. This is the same mechanism surviving at a different position: there the
caseless capital followed the cased one, here it precedes it.

Why

The acronym-tail rule in wordBoundary (ir/naming.go:97) splits at the last
capital of a run when the next rune is lowercase:

case isCapital(prev) && isCapital(r) && i+1 < len(runes) && unicode.IsLower(runes[i+1]):

On "ℤℤA" nothing follows the run in lowercase, so the first pass produces one
word and lowercases it to "ℤℤa". But lowercasing is what supplies the missing
lowercase rune: has no lowercase form and survives as a capital, while A
becomes a. The second pass now sees isCapital('ℤ') && isCapital('ℤ') with a
lowercase a following, fires the tail rule, and yields "ℤ_ℤa".

ir/naming.go:124-126 states the invariant this breaks, and states it as the
reason the two rules are safe together:

Both stay idempotent: after one pass the only capitals left are the ones
lowercasing does not change, and the tail rule needs a lowercase letter
following, which such a run does not produce on its own.

The run does not produce it — the rune after the run does, by being lowercased.
The reasoning holds for a caseless capital at the end of a name and not for one
before a cased letter.

Why it matters

Same consequence #187 recorded: the segmentation depends on the casing of the
source spelling rather than on its words, and Naming.Canonical is ABI precisely
so an emitter never has to know which spelling produced it. Canonical == CanonicalWords(Source) still holds, so irverify stays clean and nothing in the
current suite goes red — the seed "ℤℤa" is already in adversarialRunes and
passes, because the input that breaks the property is the uppercase spelling
one mutation away from it.

Reproducer

ir/testdata/fuzz/FuzzCanonicalWords_Properties/<name>:
go test fuzz v1
string("ℤℤA")

Found by a bounded -fuzz run over the existing target. The file is deliberately
not committed yet — it would redden go test ./ir for everyone before the fix
lands; commit it with the fix so the case stays pinned.

What to decide

Whether the tail rule should ask carriesCase of the rune it splits before (so a
run of caseless capitals is never a tail), or whether it should look at what the
run will become after lowercasing rather than at the source runes. The first is
the smaller change and matches the direction #187 chose for the lower→Upper rule;
it costs "ℤServer" its split, which may or may not be wanted.

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions