Kristian Rickert created OPENNLP-1852:
-----------------------------------------

             Summary: Offset-aware normalization pipeline: handle 
length-changing folds (expansion and contraction) with a single offset model
                 Key: OPENNLP-1852
                 URL: https://issues.apache.org/jira/browse/OPENNLP-1852
             Project: OpenNLP
          Issue Type: New Feature
          Components: Tokenizer
            Reporter: Kristian Rickert
            Assignee: Kristian Rickert


h2. Summary

Normalization can change string length in both directions, and today only one 
corner of the library accounts for it. Canonical and compatibility 
decompositions, full case folding, ligature and fraction expansion, and 
run-collapsing all change the number of characters between the input and the 
normalized output. The {{CharClass}} engine already produces an {{OffsetMap}} 
for its whitespace/dash folds (used by the DL components to map entity spans 
back to the original text), but the broader {{CharSequenceNormalizer}} family 
returns a plain {{CharSequence}} with no offset information. The goal of this 
issue is to make the whole normalization pipeline offset-aware so that any 
chain of expanding or contracting rungs can map a result position (an entity 
span, a search hit, a highlight) back to original-document coordinates by 
construction, not case by case.

This is a follow-up to OPENNLP-1850. The in-flight PRs already make the only 
offset-sensitive path that applies length-changing folds today (the DL 
whitespace/dash chunking in {{{}NameFinderDL{}}}) offset-safe. This issue 
generalizes that so the rest of the normalizer family can be used safely in 
offset-sensitive contexts.
h2. Background: there is a standard, and it classifies every character for us

We do not need to hand-enumerate "special characters." Unicode already catalogs 
which characters expand or contract and to what, so the work is to consume that 
data, not invent it.
||Source standard||What it covers||Direction||Notes||
|UAX #15 Normalization Forms (NFC/NFD/NFKC/NFKD)|Canonical and compatibility 
(de)composition|both|Implemented by {{{}java.text.Normalizer{}}}; no lookup 
table of our own required|
|UCD {{UnicodeData.txt}} Decomposition_Mapping + Decomposition_Type (UAX 
#44)|Per-character expansions, tagged by type: {{<fraction>}} (1/2), 
{{{}<super>{}}}/{{{}<sub>{}}} (squared to 2), {{<compat>}} (ligature fi, 
ellipsis to three dots, Roman numerals), {{{}<square>{}}}, 
{{{}<wide>{}}}/{{{}<narrow>{}}}|expand|This is the authoritative expansion 
catalog and the basis for NFKC|
|CaseFolding.txt (UTS #21 section 5.18)|Case folding, including the full 
(expanding) folds with status F: eszett to ss, ligature ff to ff|expand and 
1:1|Java's {{toLowerCase}} is locale lowercasing, not Unicode case folding, so 
full folds genuinely need this data|
|Project folds (dash, quote, ellipsis, digit, confusable)|Our own target 
choices, some derived from Unicode properties (Dash, White_Space) and some 
hand-authored|both|These are the ones with no single governing standard; see 
provenance below|

Contractions are the mirror image of the same data: NFC composition (combining 
marks fold into one precomposed character), our whitespace and dash 
run-collapses, and the UTF-16 case where a supplementary code point (two UTF-16 
units) collapses to one unit.
h2. The core idea: one offset model, not a per-character table

Expansion and contraction are the same problem stated twice. They both mean 
"the output length differs from the input length," and {{OffsetMap}} already 
solves it generically: its builder records one original offset per output 
character, so 1-to-3 (ellipsis), 3-to-1 (run collapse), and 2-to-1 
(supplementary dash) are all just "map each output position back to its 
source." Nothing in the runtime path is per-character. The character data only 
decides _what_ each fold produces; {{OffsetMap}} handles {_}where everything 
went{_}.
h2. Proposed architecture
 # *Offset-aware normalizer contract.* Give the {{CharSequenceNormalizer}} 
family an offset-aware mode that returns a {{NormalizedText}} (normalized text 
plus {{{}OffsetMap{}}}), in addition to the existing {{CharSequence}} method. A 
length-preserving rung returns an identity map; a length-changing rung builds a 
real one. Most rungs can derive the map cheaply during the pass they already 
make.
 # *Map composition.* Add {{OffsetMap.andThen(OffsetMap inner)}} (or an 
equivalent composer) so a pipeline of N rungs collapses to a single 
original-to-final map. Composition is associative and is what lets an arbitrary 
chain stay offset-safe.
 # *Offset-aware pipeline.* Add an offset-aware build/apply mode to 
{{TextNormalizer}} that runs the configured rungs in order, composes their 
maps, and returns one {{NormalizedText}} for the whole chain.
 # *Reuse the standard engines where possible.* NFC/NFD/NFKC/NFKD go through 
{{java.text.Normalizer}} with an offset map derived from the decomposition 
boundaries; we do not maintain a decomposition table. Case folding uses the 
bundled CaseFolding data. Only project-specific folds carry their own small 
tables.

The existing {{{}CharClass.normalizeMapped{}}}/{{{}collapseMapped{}}} and the 
DL {{normalizeInputMapped}} are the first instances of this pattern; this issue 
lifts the pattern up to the whole family.
h2. Data files and provenance metadata

We will bundle a CaseFolding lookup (sourced from the Unicode 
{{{}CaseFolding.txt{}}}) and aim for completeness against the upstream file. 
The key addition is {*}provenance metadata{*}: every mapping records which 
standard it follows, so we can audit coverage, distinguish standard-mandated 
folds from project choices, and re-derive the table when Unicode revises.

Proposed move from the raw {{.txt}} to a small CSV (or a {{.txt}} with a 
documented header schema) with columns roughly:
{noformat}
source       ; target          ; fold_type    ; standard                   ; 
unicode_version ; notes
00DF         ; 0073 0073       ; CASE_FOLD    ; UTS21:CaseFolding:F        ; 
17.0.0          ; LATIN SMALL LETTER SHARP S -> ss
2026         ; 002E 002E 002E  ; NFKC         ; UAX44:Decomposition:compat ; 
17.0.0          ; HORIZONTAL ELLIPSIS
2014 2013    ; 002D            ; DASH         ; UNSPECIFIED                ; -  
             ; project dash-to-hyphen target choice
{noformat}
The {{standard}} field is the important one. Suggested values:
 * {{UAX15:NFC}} / {{UAX15:NFKC}} (and the D forms) for normalization-form folds
 * {{UAX44:Decomposition:<type>}} for a specific decomposition type
 * {{UTS21:CaseFolding:<status>}} where status is C (common), F (full), S 
(simple), T (Turkic)
 * {{UNSPECIFIED}} meaning "we chose this mapping; it is not mandated by a 
published standard." This is the explicit "we just did that" marker the team 
wants, so a reviewer or auditor immediately knows the entry is a project 
decision (for example our choice of ASCII hyphen-minus as the dash target, or 
expanding the ellipsis ourselves rather than relying on NFKC).

h2. Reconciliation

The runtime fold tables will be reconciled from several sources, so we need 
explicit rules:
 # *Prefer standard engines over tables.* For anything {{java.text.Normalizer}} 
already does (NFC/NFKC/NFD/NFKD), use it and do not duplicate the mapping in a 
file. The file is for what Java does not give us: full case folding and our 
project folds.
 # *Provenance on every entry.* Each bundled mapping carries its {{standard}} 
tag (above). A build/test step audits the table against the authoritative 
upstream file for that standard (for example: every CaseFolding.txt status C 
and F row is present and identical) and fails on drift or gaps, which is how we 
keep "try to make it complete" honest.
 # *Conflict precedence.* If two sources map the same code point differently 
for the same fold type, the more specific published standard wins over a 
general one, and any deviation we deliberately keep must be re-tagged 
{{UNSPECIFIED}} with a note, never left looking standard-backed.
 # *Do we need to roll our own?* Mostly no. The open question to settle in this 
issue is the exact boundary: confirm that NFC/NFKC and case folding are fully 
delegated to standard data, and that {{UNSPECIFIED}} is reserved for the 
genuinely project-specific folds (dash target, quote folding, 
ellipsis-if-we-want-it-independent-of-NFKC, digit folding, confusable 
skeletons). Minimizing the {{UNSPECIFIED}} set is a goal.

h2. Open questions for the design review
 * CSV vs annotated {{.txt}} for the lookup, and whether one combined file or 
one file per fold type.
 * Whether the offset-aware method lives on {{CharSequenceNormalizer}} directly 
(every rung implements it) or on a parallel interface that the pipeline adapts.
 * How aggressively to expand by default: full case folding (eszett to ss) and 
NFKC change tokens visibly, so they should likely stay opt-in rungs, not 
defaults.
 * Whether {{UNSPECIFIED}} entries should be allowed in a release build at all, 
or must each be reviewed and signed off.

h2. Scope and acceptance criteria
 * {{OffsetMap.andThen}} composition with tests, including expand-then-contract 
and contract-then-expand chains.
 * An offset-aware mode on the normalizer family and on {{{}TextNormalizer{}}}, 
returning a composed {{NormalizedText}} for a multi-rung chain.
 * A bundled, provenance-tagged CaseFolding lookup with a completeness audit 
test against upstream {{{}CaseFolding.txt{}}}.
 * Round-trip tests: for representative expanding (ellipsis, eszett, ligature, 
fraction) and contracting (run collapse, supplementary dash, NFC compose) 
inputs, a position in the normalized text maps back to the correct original 
characters.

h2. Out of scope
 * The DL whitespace/dash offset-safety, already handled under OPENNLP-1850 
({{{}NameFinderDL.findInOriginal{}}} plus 
{{{}AbstractDL.normalizeInputMapped{}}}).
 * Changing any default normalization behavior; this issue adds the 
offset-aware capability and the provenance model, it does not turn expanding 
folds on by default.



--
This message was sent by Atlassian Jira
(v8.20.10#820010)

Reply via email to