Case repair reads the unjoined-particle mark, and P6 reports its fork (#407, #405) - #460
Merged
Conversation
capitalized() acts only on a name written entirely in one case; a mixed-case name is kept as written, and whether its casing is right does not enter into the decision. rules.md never said so, and R4's statement described the narrower 'an already-correct name comes back unchanged'. R4's boundary did not pin what it appeared to: 'Juan McDonald' passes under force=True as well, so it distinguished the gate from idempotence not at all. R5's witness is 'Shirley Maclaine' -- mixed-case and wrong, and kept -- and its boundary sits on the forced row, where R5's effect is absent rather than present. An earlier draft said the name is kept 'whether or not its casing is correct'. That promises a branch nothing can witness: for a mixed-case name repair would not alter, kept and repaired are the same string, so no example separates a parser with the gate from one without it. Two rows were tried and withdrawn -- one inert, one redundant with the row above it -- before the statement was reworded to claim only what is observable. decisions.md records the arc. No parser change. The two halves of the rule have different ages: the refusal predates the git history (45a1539, 2011-02-03, the initial svn import, already opens capitalize() with the guard), and bf1e0a5 (2016-06-02, shipped in 0.4.0) added the force override around it, so the rule as stated holds from 0.4.0 on. 1.4.0 is where it was measured -- the last v1 release -- not where it came from. The override has two routes, so the statement names neither: besides the per-call argument, the facade honors the v1 constant force_mixed_case_capitalization, and with it set a bare capitalize() repairs mixed case. decisions.md carries the mechanism and a 3.0 removal-candidate line; rules.md stays implementation-free. capitalized_forced is a new example pseudo-field: force is a per-call argument, not a Policy, so it cannot ride the annotation slot. decisions.md also records the debt this leaves. R4 keeps one falsehood -- 'Juan Mcdonald' is refused despite the vocabulary-exceptions promise -- and one ambiguity, 'already-correct' meaning idempotence to the rule and bearer-correct to the reader. The second wants disambiguation, not a carve-out for deliberately single-cased names, which R5's rationale declines. R4 is amended in a later commit; this one touches it only on its pointer line. Three mechanical riders the doc guards and conventions require: _render.py gains a rules.md#R5 citation comment on the gate line (no code change -- implemented: is checked against the modules that cite the rule); corpus_rules.jsonl gains 'Shirley Maclaine', 234 -> 235, with compare.py's _CORPUS_FLOORS comment moved to match and the floor itself untouched. That name is already in corpus.jsonl and the harness dedupes across corpora, so the differential population holds at 1090 and no ledger rule is needed: the gate exits 0 with 0 unexplained at the 1.4.0, 2.0.0 and 2.1.0 baselines. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`capitalized()` lowercased the words of a family name made only of particle vocabulary, while `family_base`, `family_particles` and `initials()` have read them as ordinary name words since #404 -- `parse("ANH DO").capitalized()` gave 'Anh do' with family_base 'DO' and initials 'A. D.'. _cap_word now sees the token's tags and skips the particle lowercasing when UNJOINED_TAG is present. The mark is consulted for the WHOLE PART, not for a particle standing alone: `anh van do` has family `van do`, two particle words and neither alone, and a standing-alone test would read the same surname one way behind a given name and another way alone. Only the PARTICLE conjunct is gated. Gating the conjunction conjunct too would be a NO-OP, not a mistake: a part is marked only where every word in it carries "particle", `particles` and `conjunctions` are disjoint, so a part holding a conjunction is never marked and no conjunction token can carry the mark. The code comment, the release bullet and the new test all say so now -- an earlier draft of each claimed R3 gives "a conjunction contributing nothing even in an all-particle part", which describes a configuration the parser cannot produce (`der, y van` keeps its lowercase `y` because the `y` is a GIVEN-part conjunction, not a word of the all-particle family). Deliberate 1.4.0 deviation, not a restoration: the released wheel returns 'Anh do'. Accepted cost, agreed beforehand -- a degenerate all-particle family capitalizes too, `juan van der` -> 'Juan Van Der'. docs/design/rules.md#R4 is restated, discharging the two debts decisions.md#R5 recorded: the unscoped vocabulary promise (the gate refuses before any vocabulary is read) and the ambiguous "already-correct", now disambiguated as correct-by-repair's-own- conventions. A third error surfaced while rewriting those words and is fixed too: McDonald is not a vocabulary exception at all -- the shipped exceptions are II/III/IV/M.D./Ph.D. and Mc/Mac is a separate convention -- so the statement now names one of each. R4's boundary moves to `juan de la vega` and its inert `"Juan McDonald"` row becomes a `capitalized_forced=` row that asserts what repair applies. That correction then needed a witness of its own. Naming two mechanisms where the rows only covered one reintroduced the very defect the rewrite was repairing: emptying the exceptions map left all nine R4/R5 rows green, none of their inputs holding a word the map carries. R4 gains `"john smith phd" -> capitalized="John Smith Ph.D."`, which is the only row of the ten that dies when the map is emptied (`John Smith Phd`). Chosen for having the dullest possible parse and a replacement no other rule can produce -- nothing else in the repair inserts periods -- so the row pins the map and nothing else. Reframing, on Derek's correction: case repair is not about correctness. Mixed case is the writer making an explicit choice and repair defers to it rather than judging it right or wrong. R4's closing clause no longer says a bearer's spelling is unspared -- which contradicted R5 -- but that a SINGLE-CASE spelling is repaired even where its bearer meant it, nothing in the text marking it as a choice. decisions.md#R5's withdrawn-witness bullet retires the bearer-correct frame it was written under, and records that needing a contestable claim about Dutch orthography was the symptom of it. R5's override clause is reworded from `unless repair regardless of how the name is cased was asked for` to `unless repair was asked for anyway`. The old nine words carried a second reading -- that the repair disregards the input's casing -- which is exactly the property the entry below measures and falsifies, in nearly its own vocabulary. A reader taking it that way would run the re-casing test, land on `Velasquez y Garcia, Dr. Juan Q.`, and conclude the rule was wrong when only the phrasing was. R5 also gains `"SHIRLEY MACLAINE" -> capitalized="Shirley MacLaine"`. It is the only row in that block that fails when the gate is narrowed to lowercase-only; until now R5 stated that repair acts on a name written entirely in one case and witnessed only the lowercase half. MEASUREMENT CORRECTION, recorded rather than shipped as proposed. The invariant that forcing ignores the given casing does NOT hold. Over the 1094 corpus names, `capitalize(force=True)` differs from uppercasing then repairing for 63 names and from lowercasing for 16: uppercase is the WORSE direction, and 38 of the 63 parse byte-identically and diverge inside the repair, so they are not parse-level. v1's initial carve-out is the mechanism (a one-letter conjunction is initial-shaped once uppercased), and 1.4.0 does the same. The property is pinned in tests over names with no single-letter word whose class case decides, with `juan y garcia` beside it as the recorded exception, and is deliberately NOT stated in rules.md: its examples are keyed on input strings, so stating it would invite the re-casing test that falsifies it. The repair-level half is illustrated with `Velasquez y Garcia, Dr. Juan Q.`, whose partition is byte-identical either side; the space-written form of that name is a member of the 25 whose roles do move. decisions.md#R4 records the scope choice, the deviation, the cost, and the VERIFICATION LIMIT that matters most: compare.py compares the seven role fields plus _ambiguities and never `capitalized()`, so no ledger run can see this deviation at any baseline -- half of mechanisms.md#FACADE-CONTRACT's promise cannot be kept here. The rules.md examples and the v1-facade tests stand in its place; the ones that pin the fix are mutation-checked against deletion of the tag consult, and the `juan de la vega` control passes that mutation by design, which is what makes it a control. A follow-up issue for a capitalization column is left as a forward reference; the entry records why one was not added now (1.4.0 and 2.1.0 already disagree on `capitalize(force=True)` for 139 of 1090 corpus names, nearly all echoes of parse diffs the field comparison explains, so it needs a diff-of-diffs design rather than a column). Gate: corpus 1094 (was 1090 -- `anh van do`, `juan de la vega`, `SHIRLEY MACLAINE` and `john smith phd` are new), intentional diffs 229/194/102 at 1.4.0/2.0.0/2.1.0, unexplained 0 at all three -- unmoved, as expected for a change to an uncompared field. corpus_rules.jsonl 235 -> 240; its floor comment updated, the floor itself unchanged. Ledger guards pass with no reach recount. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
rules.md#A1 requires a genuinely uncertain reading to carry a report. P6's tussenvoegsel attachment creates such a fork and reported nothing: 'Van Johnson' reports particle-or-given while 'Nguyen, Thi Van' decided the same fork silently and lost the given name. Two kinds, keyed on the reading the attachment OVERRODE rather than on what the words are. Where assign had read the run as a post-nominal, that is what was declined (suffix-or-name); otherwise, where the run holds an ambiguous particle, the name-word reading was declined (particle-or-given); otherwise nothing was decided and nothing is reported. Keying on vocabulary instead gets both collision words backwards: 'do' is suffix vocabulary yet reports particle-or-given, having never been read as a credential, and 'Berg, Jan de vd' reports nothing though 'vd' is. Both are pinned in cases.py. The arms are ordered, not asserted disjoint. The plan's premise -- a suffix-vocabulary particle arrives suffix-roled and IS the whole run -- holds for the shipped vocabulary (0 both-arm runs over 29,400 generated names) but not for the rule: a caller's Lexicon can put one word in both sets, and the assert fired on 'Berg, Jan zz'. A1 forbids raising from a stage, so the suffix arm wins by statement. assign's 'deliberately not emitted' scoping was true of the family -- which the comma fixes -- and not of the particle behind it. post_rules gains ambiguities in the stage ownership map. Ledger: no new rule. Against 2.0.0 and 2.1.0 these seven names already diff in middle/family (or family/suffix) from #379 and #380, and classify matches on `fields` being a superset of the whole diff, so an _ambiguities-only rule explains none of them. The two existing rules are widened instead, in both ledgers; each still passes #452's declared == union check. 1.4.0 needs nothing. Gate output is byte-identical before and after at all three baselines: 1094 names, 229/194/102 intentional, 0 unexplained, no per-heading count moved. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
R4's verification-limit entry pointed forward to an unfiled issue for a capitalization column in the differential harness. That issue is not coming: the harness is scoped to parsing behavior, where a role is emergent across the eight-stage fold and the reach of a change has to be measured because it cannot be read off the diff. Case repair is a pure function of (word, role, tags, lexicon) with four branches behind one gate, so a change to it is legible in the change; a case column would mostly re-report parse diffs the field comparison already explains, and the residue belongs in unit tests. The entry now states that as a scope rather than as a gap, and the following bullet is rewritten as a settled decision instead of a deferral. The load-bearing fact is kept: compare.py compares the seven role fields plus `_ambiguities` and never `capitalized()`. Stale prose the branch's two behavior changes left behind. The AmbiguityKind docstrings are the API reference through autodoc and were written for the pre-#405 world: PARTICLE_OR_GIVEN said the fork is reported for a particle "at the head of a name" in "two shapes", where P6's attachment now makes a third at the tail after a family comma, and SUFFIX_OR_NAME's coverage list did not include the family-comma abbreviation it now reports. docs/concepts.rst told readers that "the comma paths stay quiet on purpose", which #405 made false; it now says what a comma actually settles and names the listing that still reports. No other site had prose to move -- AGENTS.md's ambiguity bullet was already corrected by the #405 commit, and README.rst, docs/usage.rst, docs/customize.rst, docs/migrate.rst and docs/modules.rst carry only working-particle examples that this branch does not reach. corpus_rules.jsonl's count in _CORPUS_FLOORS read 240; the #405 commit added a rules.md example after it was written. It is 241. OUT OF SCOPE FOR THIS BRANCH, but adjacent enough to fix while the files are open: all three expected_since_*.toml headers said `fields` "narrows further and is optional". That stopped being true when #456/#457 landed on 2026-08-29 -- validate_rules already refused a rule with `fields` and no `name_regex` (#451) and now refuses the mirror shape, so both keys are required. Each header is corrected in its own words. Verified at HEAD: 6114 passed / 156 skipped / 9 xfailed, mypy and ruff clean, and the gate green at 1094 names with 229/194/102 intentional diffs and 0 unexplained at all three baselines. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
'all 39 ambiguous particles' describes a past bug that fired for every member of the set; the number adds nothing to that point and had already drifted -- particles_ambiguous holds 37 today. Same edit in the three places the phrase appears (AGENTS.md, mechanisms.md and the cases.py row that quotes it), plus the 'only 9 of the 39' ratio in decisions.md's Excluded block, which is the same standing-count shape. decisions.md#vocabulary-collisions keeps its 39: that one is labelled 'Sizes as of 2.2.0dev' and followed by 'recompute before quoting them', which is the dated-snapshot form AGENTS.md's counting rule permits. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## master #460 +/- ##
=======================================
Coverage 98.64% 98.65%
=======================================
Files 45 45
Lines 3177 3186 +9
=======================================
+ Hits 3134 3143 +9
Misses 43 43 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
P6's two ambiguity details said the trailing run "is read as the
family's particle", which every other view contradicts: R2's mark
runs later in the same stage and can make the whole attached part
ordinary name words -- `de la, Jan van` gives family_base 'van de
la', initials 'J. d. l. v.' and 'Jan Van De La' forced, while the
report said "particle". Both details now name the ATTACHMENT that P6
actually decides -- the run joins the family the comma named rather
than standing on its own -- which is true whether or not R2 later
marks the part. The emit stays at the decision site.
_render._cap_word's conjunction conjunct is correctly left ungated on
the unjoined mark; only the comment's reason was false. It claimed
`particles` and `conjunctions` are disjoint, so no conjunction could
ever carry the mark; both sets are public API, and under
`Lexicon.default().add(particles={'y'})` `anh y van` has an
all-particle family whose `y` carries the mark and gives 'Anh y Van',
where gating the conjunct gives 'Anh Y Van'. The real reason is R3's
own carve-out, now stated in rules.md#R4 as well, with the
disjointness claim scoped to the shipped vocabulary (measured empty
in the default lexicon and all four locale packs). Same claim fixed
at tests/test_capitalization.py.
Also, all re-measured:
- rules.md#R4: "Mac/Mc surname convention" -> "Mac/Mc convention"
(_MAC has no role test; `mackenzie smith` capitalizes to
'MacKenzie Smith' with `mackenzie` in the GIVEN role).
- release_log #405 bullet: the tussenvoegsel fix moved NINE names at
2.1.0, not seven -- fix(#379) explains 7 and fix(#380) 2; the seven
gaining a kind are five of #379's plus both of #380's, and the two
left out are the `Jong` pair the same bullet already lists as
unchanged. Standing population count dropped. Its "the fix above"
pointer was also backwards; the #379 bullet sits below it.
- AGENTS.md: TITLES cap particles_ambiguous is {freiherr, st}, not
{st, do, freiherr}; and `Dr. Do van Johnson` / `Do St Johnson`
emit from `assign`, not `group`, so both worked examples were
wrong. Replaced with `Dr. St van Johnson` / `St van Johnson`
(reach it) and `Jan Freiherr von Richthofen` / `Jan St Johnson`
(do not), measured by stepping STAGES. Dangling concepts.rst
cross-reference dropped; "Structure also structure" typo fixed.
- tests/v2/pipeline/test_post_rules.py: both arms' detail text and
the reported token span are now asserted. Mutation-checked outside
the worktree -- constant details and `tuple(run[:1])` each left the
full suite green before, and each kills a new test now.
- rules.md#R4 + decisions.md#R4: the replace()/revise() boundary,
recorded as an Accepted boundary rather than a gap. `_cap_word`
keys the particle TEST on lexicon membership and the REPAIR on the
mark, and the mark is a parse product, so a family spliced in by
`replace()` as 'de la' stays lowercase where a parsed one gives
'De La'. `Parser.revise()` is the supported crossing and already
promised it -- measured, it gives 'De La' -- so its enumeration of
the tag-driven views gains `capitalized()`, the fourth since #407.
Recorded too: for an all-particle part the other three tag-driven
views converge across `replace()` and `revise()` (measured over
`de la`, `van der`, `do`, `de`, `van de la`), and on a mixed part
they diverge while case repair agrees -- which is why the boundary
was invisible until #407 made case repair tag-driven.
- decisions.md blast radius: 4 and 23 movers over the 1090 names the
corpora carried before this change, 5 and 24 over today's 1094,
both now stated (`anh van do` is the added mover).
- decisions.md / tests/test_capitalization.py: the re-casing figures
are 63/25/38 through the v2 core and 62/25/37 through HumanName,
whose default render spec omits the maiden name and so cannot see
`Jane van der Berg nee y Jones`. Each now names its surface.
- decisions.md: the 11 comma-plus-trailing-particle corpus names came
from the #379/#380 arc alone; this change added four names and none
carries a comma.
- _state.py and Parser.revise()'s docstrings reflowed; capitalized()
now says it reads token tags, matching initials(); the R4/R5
duplicate example row is kept and recorded as deliberate (five
other rows already sit under two rules apiece).
Gate green at all three baselines: corpus 1094, intentional diffs
229 / 194 / 102, unexplained 0. corpus_rules.jsonl unchanged at 241.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Bundle 4's head: two render/report fixes that share a review pass rather than any code.
Closes #407
Closes #405
Why R5 comes first
Writing #407's R4 examples exposed that rules.md never documented the case-repair gate, and that R4's statement was narrower than the parser's behavior — it promised "an already-correct name comes back unchanged" where the parser keeps any mixed-case name, correct or not.
Worse, R4's boundary example did not pin what it appeared to:
"Juan McDonald"passes underforce=Trueas well, so it was consistent with both idempotence and the gate and distinguished neither.Shirley Maclaine— mixed case, wrong, and kept — is the witness that separates them.So the first commit extracts R5 and changes no behavior. Verified on the released 1.4.0 wheel: the refusal predates git (
45a1539, 2011-02-03, the svn import), and theforceoverride arrived inbf1e0a5(2016-06-02, shipped in 0.4.0).capitalized_forcedis a new example pseudo-field, becauseforceis an argument rather than a policy and cannot ride an example's annotation slot.#407 — case repair reads the unjoined-particle mark
post_rulesmarks a name part whose every word is particle vocabulary: nothing joins them to a name, so they are not doing a particle's work. Three views already read the mark. Case repair did not, soANH DOcapitalized toAnh dowhilefamily_basereadDOas an ordinary name word.Whole part, not standing-alone —
rules.md#R2's test is "'Every word', not 'standing alone'", andanh van dohas a two-word all-particle family that a narrower rule reads wrong on a real Vietnamese name.This deviates from 1.4.0 deliberately and does not restore it: 1.4.0 returns
Anh do. The accepted cost is that a degenerate all-particle family capitalizes too, sojuan van dergivesJuan Van Der.How it is verified, given the gate cannot see it.
compare.pycompares the seven role fields plus_ambiguities; the stringcapitalizdoes not occur in it. That is by design rather than a gap — the harness exists for parsing behavior, where role assignment is emergent across the stage fold and the blast radius cannot be read off a diff, while case repair is a four-branch pure function. The deviation is pinned by therules.md#R4examples andtests/test_capitalization.py, anddecisions.md#R4records the limit.While rewriting R4's statement:
McDonaldwas never a vocabulary exception.CAPITALIZATION_EXCEPTIONSis{ii, iii, iv, md, phd};McDonaldcomes from the separate Mac/Mc convention. R4 had been illustrating its promise with the one mechanism it was not about, and now names both separately — each with a row that dies when its own mechanism is removed.#405 — P6's attachment reports the fork it decides
rules.md#A1requires a genuinely uncertain reading to carry a report.Van Johnsonreportsparticle-or-given;Nguyen, Thi Vandecided the same fork silently and lost the given name._assign's scoping — "after a comma that fixed the family, a leading given-position particle is not meaningfully ambiguous" — was true of the family, which the comma fixes, and not of the particle behind it, which P6 decides separately.Two kinds, keyed on the reading P6 overrode rather than on the word's vocabulary:
middle)PARTICLE_OR_GIVENsuffix—vd,mc)SUFFIX_OR_NAMEKeying on vocabulary instead is backwards for both collision words:
Berg, Jan doreportsparticle-or-given, becausedosits in the AMBIGUOUS acronym half and no credential reading was ever taken; andBerg, Jan de vdreports nothing, because behinddethe run was read as name words. Both now have case rows.The arms are ordered, suffix first, rather than asserted disjoint. A legal custom
Lexiconadding a word to bothparticles_ambiguousandsuffix_acronymsmakes both true, and an assert there would raise on a parse — a totality violation of A1, whose only sanctioned exception is a caller's segmenter.Beethoven, Ludwig vannow carries a report. The textbook-correct Dutch listing gains one because the parser genuinely cannot distinguish it fromNguyen, Thi Van— the ASCII transliteration is the same string in both traditions. That is the most user-visible consequence of this PR.post_rulesgainsambiguitiesin the stage-ownership map, and theParseStatedocstring twin moves with it.Ledger
Seven names gain an ambiguity kind. The plan called for a new
_ambiguities-only rule per ledger; that explains nothing, becauseclassifymatches onfieldsbeing a superset of the whole diff and these names already diff inmiddle/familyfrom #379/#380. The existing rules were widened instead, each still satisfying #452'sdeclared == unioncheck. 1.4.0 needs nothing —_ambiguitiescannot enter a diff below baseline 2.0.Verification
unexplained: 0. Per-heading counts diffed before and after: identical.Review round
A four-agent review (code, tests, comments, error handling) found no Critical issues and nine Important ones, all fixed in the final commit. Highlights: the emitted
detailsaid the run is "read as the family's particle" where R2's later mark can make every other view read it as a name word — reworded to name the attachment instead; thedetailtext and reported token span were pinned by nothing (both mutations left the suite green) and now have assertions; the release log said the tussenvoegsel fix moved seven names where the gate says nine; andAGENTS.mdhad re-landed two stale claims in a line this PR rewrote.The test pass ran 22 mutations and found no inert row — every new test,
cases.pyrow andrules.mdexample is killed by a mutation of the exact expression it pins. Totality was checked across ~250,000 parses under adversarial lexicons, randomized vocabularies, four locale packs and 24 policy combinations: zero exceptions.Three issues were spun off from the review: #459, #461 (both found while measuring), and #458 above.
Out of scope but adjacent, called out deliberately
fields"is optional", which Deleting a rule'sfieldsescapes #452's check and makes the rule maximally permissive — the symmetric twin of #451 #456 made false on 2026-08-29. Corrected in the same sweep, each in its own wording.AGENTS.md,mechanisms.mdand acases.pyrow. The set holds 37 today.decisions.md#vocabulary-collisionskeeps its figures: they are labelled "as of 2.2.0dev" and followed by "recompute before quoting them", which is the dated-snapshot form the counting rule permits.docs/concepts.rstwere made stale by A tussenvoegsel attachment decides a particle-or-given fork and reports no ambiguity, which A1 requires #405 and fixed here.concepts.rsthad said "the comma paths stay quiet on purpose", the exact claim this PR reverses.Spun off, not fixed here
conjunction/initialtags instead of re-deciding from the word #458 — render views should honor the parser'sconjunction/initialtags instead of re-deciding from the word. Measured a pure refactor: 0 of 1094 names move.HumanName('JOHN SMITH MBA').capitalize()givesJohn Smith Mba— a credential acronym is title-cased as an ordinary word #459 — a credential acronym is title-cased as an ordinary word (JOHN SMITH MBA→John Smith Mba).🤖 Generated with Claude Code