[
https://issues.apache.org/jira/browse/OPENNLP-1852?page=com.atlassian.jira.plugin.system.issuetabpanels:all-tabpanel
]
Kristian Rickert updated OPENNLP-1852:
--------------------------------------
Issue Type: Epic (was: New Feature)
> 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
>
> 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. Open questions for the design review
> * *How NFC/NFKC/case-fold rungs produce an {{Alignment}}* given
> {{java.text.Normalizer}} exposes no edits: ICU4J {{Normalizer2}}/{{Edits}},
> an input/output diff, or leaving those folds offset-opaque. (The decision
> that gates everything else.)
> * 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.
> * Which dataset anchors each annotation dimension (an emoji sentiment ranking
> for sentiment, CLDR for names, and what for entity type and category), and
> whether the annotations live in the same lookup or a separate file keyed by
> code point.
> * Whether emoji and emoticons classify as punctuation or as a dedicated emoji
> {{WordType}} for counting and feature-generation purposes.
> 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. Out of scope
> * The DL whitespace/dash offset-safety, already handled under OPENNLP-1850
> ({{NameFinderDL.findInOriginal}} plus {{AbstractDL.normalizeInputAligned}}).
> * The {{Alignment}} model and {{andThen}} composition themselves, delivered
> under OPENNLP-1850; this issue consumes them.
> * 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)