[ 
https://issues.apache.org/jira/browse/OPENNLP-1852?page=com.atlassian.jira.plugin.system.issuetabpanels:all-tabpanel
 ]

Martin Wiesner updated OPENNLP-1852:
------------------------------------
    Fix Version/s: 3.0.0

> 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: Epic
>          Components: Tokenizer
>            Reporter: Kristian Rickert
>            Assignee: Kristian Rickert
>            Priority: Major
>             Fix For: 3.0.0
>
>
> 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 {{Alignment}} 
> (carried in an {{AlignedText}}) 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. That work delivered the offset *model* 
> this issue builds on (see "Already delivered" below) and made the only 
> offset-sensitive path that applies length-changing folds today (the DL 
> whitespace/dash chunking in {{NameFinderDL}}) offset-safe. This issue 
> generalizes the model across the rest of the normalizer family so it can be 
> used safely in offset-sensitive contexts.
> h2. Already delivered in OPENNLP-1850 (do not re-scope here)
> The keystone is in place; this issue should build on it rather than reinvent 
> it.
> * {{Alignment}} -- a bidirectional edit-sequence offset model 
> ({{equal}}/{{replace}} runs) that represents expansion, contraction, deletion 
> (as gaps), and 1:1 changes uniformly, and maps span-to-span with 
> {{toOriginalSpan}}/{{toNormalizedSpan}}. It replaced the earlier 
> per-output-character {{OffsetMap}}, which assumed the normalized text 
> contiguously covered the original and over-covered deletions. ({{OffsetMap}} 
> and {{NormalizedText}} were never released and are removed.)
> * {{Alignment.andThen(Alignment)}} -- associative composition of two stages 
> into one original-to-final alignment. Built and tested, including 
> expand-then-contract and contract-then-expand chains.
> * The offset-aware *contract* on {{CharClass}}: {{normalizeAligned}}, 
> {{collapseAligned}}, {{collapsePreservingAligned}}, {{trimAligned}}, 
> {{removeAllAligned}}, each returning an {{AlignedText}} (normalized text plus 
> {{Alignment}}).
> * The DL path consuming it: {{AbstractDL.normalizeInputAligned}} and 
> {{NameFinderDL.findInOriginal}} map decoded spans back via 
> {{Alignment.toOriginalSpan}}.
> So the remaining work is to lift the offset-aware contract from {{CharClass}} 
> up to the whole {{CharSequenceNormalizer}} family and the {{TextNormalizer}} 
> pipeline, add the provenance-tagged data, and add the emoji/emoticon rung and 
> annotation layer.
> 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}} -- but see the edit-tracking caveat under Proposed 
> architecture: it returns a plain string with no edit information |
> | 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: the output 
> length differs from the input length. {{Alignment}} solves it generically. It 
> records the edits between input and output as a sequence of _equal_ runs 
> (text copied through, length unchanged) and _replace_ runs (M original 
> characters that produced P normalized characters), so 1-to-3 (ellipsis), 
> 3-to-1 (run collapse), 2-to-1 (supplementary dash), and N-to-0 (deletion) are 
> all just runs. It maps span-to-span, so a match that ends next to deleted 
> text reports a tight original span instead of over-covering the deletion, and 
> it composes with {{andThen}}. The character data only decides _what_ each 
> fold produces; {{Alignment}} handles _where everything went_.
> h2. Proposed architecture
> # *Offset-aware normalizer contract.* Give the {{CharSequenceNormalizer}} 
> family an offset-aware mode that returns an {{AlignedText}} (normalized text 
> plus {{Alignment}}), in addition to the existing {{CharSequence}} method. A 
> length-preserving rung returns an identity alignment; a length-changing rung 
> builds a real one. The {{CharClass}}-backed rungs derive it cheaply during 
> the pass they already make; the standard-engine rungs are the hard case (next 
> point).
> # *Map composition.* {{Alignment.andThen}} -- *already delivered*. A pipeline 
> of N rungs collapses to a single original-to-final alignment by folding 
> {{andThen}} over the stages.
> # *Offset-aware pipeline.* Add an offset-aware build/apply mode to 
> {{TextNormalizer}} that runs the configured rungs in order, composes their 
> alignments, and returns one {{AlignedText}} for the whole chain.
> # *Reuse the standard engines where possible -- with a known edit-tracking 
> gap.* NFC/NFD/NFKC/NFKD and full case folding are the value of delegating to 
> standards, but {{java.text.Normalizer.normalize(s, form)}} returns only the 
> result string with *no edit information*, so an {{Alignment}} cannot be read 
> out of it directly. The realistic options, to be decided in design review: 
> (a) depend on ICU4J's {{Normalizer2}}, which can emit an {{Edits}} object 
> that maps directly onto our {{equal}}/{{replace}} runs; (b) compute the 
> alignment by diffing input against output at decomposition boundaries; or (c) 
> restrict the offset-aware path to folds we generate ourselves (the 
> {{CharClass}} family plus a bundled-table case fold) and treat 
> {{java.text.Normalizer}}-only NFC/NFKC as offset-opaque. This is the single 
> biggest design decision in the issue and should be settled first.
> The {{CharClass}} {{*Aligned}} family and the DL {{normalizeInputAligned}} 
> are the first instances of the offset-aware contract; 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                   ; 
> sentiment ; unicode_version ; notes
> 00DF         ; 0073 0073      ; CASE_FOLD ; UTS21:CaseFolding:F        ;      
>      ; 17.0.0          ; sharp s -> ss
> 2026         ; 002E 002E 002E ; NFKC      ; UAX44:Decomposition:compat ;      
>      ; 17.0.0          ; horizontal ellipsis
> 2014 2013    ; 002D           ; DASH      ; UNSPECIFIED                ;      
>      ; -               ; dash-to-hyphen target choice
> 1F600        ; 003A 0029      ; EMOJI     ; CLDR:annotation            ; 
> positive  ; 17.0.0          ; grinning face -> :)  (expansion)
> 003A 0029    ; 1F642          ; EMOTICON  ; UNSPECIFIED                ; 
> positive  ; -               ; :) -> slightly smiling face (contraction)
> {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. Emoji and emoticons, both directions, sentiment, and token classification
> Emoji-to-emoticon and emoticon-to-emoji are exactly the bidirectional, 
> length-changing case this issue is built for: an emoji rendered as {{:)}} is 
> an expansion, the reverse a contraction, and a supplementary-plane emoji is 
> also the two-UTF-16-units-collapsing case. Both directions are in scope, 
> riding the same offset-aware rung and lookup table. There is no governing 
> standard for the mapping itself (emoticons are informal and the relation is 
> many-to-many and culturally variable), so most rows are tagged 
> {{UNSPECIFIED}}; where a direction can be anchored on published data we do 
> (for example CLDR emoji annotations for the emoji short-name) and tag it 
> accordingly.
> The bigger payoff is turning an opaque pictograph into *lexical signal*. 
> Beyond the emoticon form, the same lookup can carry the emoji's actual text, 
> its CLDR short-name (for example "grinning face"), so the rung can surface a 
> real word sequence instead of an out-of-vocabulary symbol that most models 
> simply drop. That text becomes the input that sentiment analysis, semantic 
> search, embedding pipelines, and LLM-style consumers actually read and reason 
> about, which is where this helps downstream understanding the most. The 
> {{Term}} model is the natural carrier: one token can expose {{original}} (the 
> emoji), a folded {{emoticon}}, a human-readable {{name}}, and a {{sentiment}} 
> tag as parallel layers, and each consumer picks the layer it needs (a 
> sentiment lexicon match, the name text for an embedding, or the original for 
> display). Sentiment carries its own provenance via a {{sentiment_source}} 
> field (an emoji sentiment ranking dataset, a project lexicon, or 
> {{UNSPECIFIED}}), kept separate from the mapping's {{standard}} tag so each 
> is independently auditable and replaceable.
> The same annotation mechanism generalizes well beyond sentiment. An emoji's 
> name is frequently an entity or concept in its own right (an airplane, a 
> tower, the Statue of Liberty, a national flag), so surfacing that name plus 
> an optional coarse entity-type tag (for example VEHICLE, LANDMARK, ANIMAL, 
> FLAG, FOOD) gives the Name Finder real lexical content and a gazetteer-like 
> signal where it would otherwise see an unknown glyph. The same row can also 
> carry a document-category hint (a pizza or an airplane is a strong cue for a 
> food or a travel document), feeding Document Categorizer features. So the 
> lookup is less a single sentiment column and more a small, provenance-tagged 
> annotation layer per symbol: name, sentiment, entity type, and category, each 
> optional and each with its own source, surfaced through the {{Term}} model 
> and consumed by the Sentiment Detection, Name Finder, and Doccat components 
> respectively. Each annotation dimension is opt-in, so a consumer that only 
> wants the folded text pays nothing for the rest.
> Token classification ties in too. The UAX #29 tokenizer already gives emoji 
> their own {{WordType}} category, so the same data lets downstream counting 
> and feature generation treat emoji and emoticons as a consistent class rather 
> than stray symbols, for example folding them into a punctuation-like bucket 
> or a dedicated emoji feature for corpus statistics and the ML feature 
> generators. That keeps a document's token counts and features stable whether 
> sentiment is expressed as {{:)}}, as an emoji, or as the spelled-out name.
> 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 for the 
> *text* and do not duplicate the mapping in a file (the offset-aware question 
> for these is the edit-tracking gap above, not the mapping itself). 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. Design decisions (from OPENNLP-1850; assumed baseline on main)
> The following were settled during OPENNLP-1850 and are already shipped. This 
> issue builds on them rather than reopening them.
> * *Offset-aware contract.* A parallel {{OffsetAwareNormalizer}} interface 
> (extends {{CharSequenceNormalizer}}, adds {{normalizeAligned()}}). 
> {{TextNormalizer.Builder.buildAligned()}} composes only rungs that implement 
> it and throws {{IllegalStateException}} naming the offending rung otherwise. 
> Callers probe with {{instanceof OffsetAwareNormalizer}} (same pattern as 
> {{OffsetMappingNameFinder}} in the DL layer).
> * *Standard-engine folds in aligned pipelines.* NFC, NFKC, accent fold, 
> confusable skeleton, and case fold delegate to {{java.text.Normalizer}} or 
> JDK case mapping and *cannot* report per-character edits. They are excluded 
> from {{buildAligned()}} in 1850; making them offset-aware is the core work of 
> *this* issue.
> * *Default vs opt-in behavior.* {{TextNormalizer.defaultChain()}} is the 
> conservative matching chain: strip invisible, NFC, whitespace, quotes, 
> dashes, case fold, accent fold. DL input folding (whitespace and dashes) is 
> opt-in via {{InferenceOptions}}. NFKC is not in {{defaultChain()}}. This 
> issue does not change default normalization behavior; offset-aware capability 
> and provenance-tagged lookups are additive.
> * *Emoji classification.* {{WordType.EMOJI}} is a dedicated category (not 
> punctuation). Classification precedence is emoji > script > 
> alphanumeric/numeric. {{Extended_Pictographic.txt}} is a filtered UCD subset 
> bundled and cursor-parsed like the other 1850 data files.
> * *Bundled data format (1850 precedent).* Line-oriented {{.txt}} resources, 
> cursor-parsed at load time, fail-loud on malformed input. No regex in the 
> parse path. Provenance columns are not present in 1850 data files; they are 
> introduced in this issue for CaseFolding and project-specific mappings.
> h2. Remaining design work
> * * *How NFC/NFKC/full case fold produce an {{Alignment}}.* 
> {{java.text.Normalizer}} returns only a string. Choose and implement one of: 
> ICU4J {{Normalizer2}} + {{Edits}}, input/output diff at decomposition 
> boundaries, or a bundled CaseFolding lookup that generates the alignment 
> during our own pass. Evaluate ICU dependency vs diff complexity in the first 
> implementation PR.
> * *Provenance-tagged lookup format.* Extend the 1850 {{.txt}} + cursor-parse 
> pattern with a documented header/schema for {{standard}}, {{fold_type}}, and 
> {{UNSPECIFIED}} project choices; or introduce a small CSV if columnar 
> provenance is clearer. One file per fold type for bundled tables (consistent 
> with {{WordBreakProperty.txt}}, {{confusables.txt}}, etc.).
> * *{{UNSPECIFIED}} entries.* Allowed for genuine project choices (dash 
> target, quote targets, emoji/emoticon mappings with no governing standard). 
> Each must carry an inline note. Standard-backed rows must audit clean against 
> the upstream file. No separate sign-off gate in the build for 1850-style 
> project folds.
> * *Emoji/emoticon annotation layer.* Bidirectional emoji↔emoticon rung plus 
> optional {{Term}} layers (name, sentiment, entity type, category). CLDR 
> annotations for names where available; sentiment/entity/category sources and 
> file layout decided in the first annotation PR. Annotations in a separate 
> lookup file keyed by code point, not mixed into the fold table.
> h2. Scope and acceptance criteria
> * (DONE in OPENNLP-1850) {{Alignment.andThen}} composition with tests, 
> including expand-then-contract and contract-then-expand chains.
> * (DONE in OPENNLP-1850) The offset-aware contract on {{CharClass}} returning 
> {{AlignedText}}, with round-trip tests for collapse, supplementary-dash 
> contraction, supplementary-replacement expansion, and edge deletions.
> * An offset-aware mode on the rest of the {{CharSequenceNormalizer}} family 
> and on {{TextNormalizer}}, returning a composed {{AlignedText}} for a 
> multi-rung chain (requires resolving the NFC/NFKC edit-tracking question).
> * 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 through the full pipeline.
> * A bidirectional, opt-in emoji/emoticon rung backed by the same 
> provenance-tagged lookup, offset-aware like every other rung, with round-trip 
> tests in both directions.
> * A small, provenance-tagged annotation layer on the lookup (name, sentiment, 
> entity type, category), surfaced through the {{Term}} model and wired as 
> optional input to the Sentiment Detection, Name Finder, and Document 
> Categorizer components.
> * Consistent {{WordType}}-level classification of emoji and emoticons so 
> token counts and ML feature generators treat them as one class.
> h2. Design guidelines and codebase consistency
> OPENNLP-1850 merged the normalization engine the rest of this work builds on. 
> The following are already in place on {{main}} and are assumed by this issue:
> * {{CharClass}} / {{CodePointSet}} cursor engine — UCD-sourced 
> {{White_Space}} and {{Dash}} sets, no regex on text paths
> * {{Alignment}} / {{AlignedText}}, {{OffsetAwareNormalizer}}, 
> {{TextNormalizer.buildAligned()}}, and the {{CharClass}} {{*Aligned}} variants
> * UAX #29 word tokenizer ({{WordSegmenter}}, {{WordTokenizer}}, {{WordType}})
> * {{Term}} / {{TermAnalyzer}} and per-language {{NormalizationProfile(s)}}
> * DL integration: Unicode {{White_Space}} chunking, optional input folding, 
> {{NameFinderDL.findInOriginal()}} via {{Alignment}}
> h3. Design principals
> Three design goals govern that engine and should govern new normalization 
> work going forward:
> # *Standards-sourced character classes.* UCD properties ({{White_Space}}, 
> {{Dash}}, …), not {{Character.isWhitespace}} or regex character classes like 
> {{\s}}.
> # *Cursor-based text transforms.* Single forward code-point scans for 
> normalization and token-boundary logic on user text. Regex is fine for config 
> parsing and other non-user-text utilities.
> # *Offset preservation where spans matter.* Length-changing folds compose 
> through {{Alignment}} so consumers can map normalized positions back to the 
> original input. That is this issue's job for the standard-engine folds 
> {{buildAligned()}} rejects today; the {{CharClass}} rungs and DL path already 
> do it.
> The manual (OPENNLP-1850 docs) should describe these as *design goals* the 
> engine follows and that new code should follow. Do not claim every legacy 
> class in {{opennlp.tools.util.normalizer}} or all of OpenNLP already meets 
> them — several pre-existing normalizers still use regex and {{StringUtil}} 
> still uses JVM whitespace predicates. That gap is tracked below, not a defect 
> in the merged engine.
> Target manual wording ({{normalizer.xml}} intro):
> {quote}
> The normalization engine follows three design goals: standards-sourced 
> character classes, cursor-based text transforms, and offset preservation 
> where spans matter. New normalization code should align with these goals; 
> older components are migrated on a best-effort basis (tracked in 
> OPENNLP-1852).
> {quote}
> State the no-regex / UCD rationale once in the normalizer chapter; DL and 
> tokenizer chapters should xref rather than restate.
> h3. Consistency scoreboard
> Sub-tasks to file under this epic when ready; until then this table is the 
> scoreboard:
> || # || Summary || Component || Priority || Status ||
> | 1 | Audit {{split("\\s+")}} and {{Character.isWhitespace}} call sites 
> outside the 1850 engine | codebase-wide | medium | todo |
> | 2 | Migrate {{StringUtil}} whitespace to UCD-backed API or document 
> intentional exception | {{opennlp-api}} | medium | todo |
> | 3 | Replace regex in legacy {{TwitterCharSequenceNormalizer}} | 
> {{opennlp-runtime}} | low | todo |
> | 4 | Replace regex in legacy {{UrlCharSequenceNormalizer}} | 
> {{opennlp-runtime}} | low | todo |
> | 5 | Replace regex in legacy {{ShrinkCharSequenceNormalizer}} | 
> {{opennlp-runtime}} | low | todo |
> | 6 | Replace regex in legacy {{NumberCharSequenceNormalizer}} | 
> {{opennlp-runtime}} | low | todo |
> | 7 | Replace regex in legacy {{EmojiCharSequenceNormalizer}} | 
> {{opennlp-runtime}} | low | todo |
> | 8 | Audit spellcheck / extension normalizers for regex-on-text paths | 
> extensions | low | todo |
> | 9 | Soften manual intro to design-goals wording; trim duplicated 
> no-regex/ReDoS prose across chapters | {{opennlp-docs}} | medium | todo |
> | 10 | Add design-guidelines xref in {{normalizer.xml}} pointing here | 
> {{opennlp-docs}} | low | todo |
> When the scoreboard is substantially green, tighten the manual from "design 
> goals" to stronger statements. Scoreboard items do not block the offset-aware 
> fold work in this issue.
> h2. Out of scope
> * * Changing default normalization behavior; this issue adds offset-aware 
> capability and the provenance model, it does not turn expanding folds on by 
> default.
> * Legacy regex normalizer migration, {{StringUtil}} whitespace 
> standardization, and manual wording — tracked in the scoreboard above, not 
> acceptance criteria for this issue.



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

Reply via email to