langextract 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 18f4df67c5283fcde932a3a75aa0d4c0a02978b90a56d68f44219dc89a8081fe
4
- data.tar.gz: d0cc9f1c38f50535e365adbabfa91ee31d69713716a3ec0fd13f9f05b98cb606
3
+ metadata.gz: 36a0e06e39f5d20e93edcfb3ff08982019bf38f350b13145a850667763e10dae
4
+ data.tar.gz: b6249cecfd2b08bc6e98c1806df03b661336d9731638994468b92fdd58a09894
5
5
  SHA512:
6
- metadata.gz: deb42f645fabcea65211bc2bc12348f6ba278fa3e1213c8d54c548b1006515e1d442c6aea9a09bb88431401812acaf1400a01aa97f23403af8977f9f59f370c1
7
- data.tar.gz: d3d9f43beef6ace15dd65c24250bb991066d5b6f0d7dbf3a073e36f087f157e02633f1273b7476d738b8ea9ea1294b5b12718eedd3dba0d27d0d99a750ca4a06
6
+ metadata.gz: 18f077f29df06209acc5565692fb633801df636c758dde967a76003dc837f4426e17189fb1df75cb667ff0313626308eac95dab0b710c364f1d59b25fcb3e3f7
7
+ data.tar.gz: 28229dbda5f455dea01d1293d0f8c5297f5222a77d6c5040d821d62f72de95109b19be8f572d04e852e3e210ba7a96c772f82b9920ae4253d84d923becd6e5e3
data/CHANGELOG.md CHANGED
@@ -2,6 +2,20 @@
2
2
 
3
3
  All notable changes to this project will be documented in this file.
4
4
 
5
+ ## [0.4.0] - 2026-07-16
6
+
7
+ ### Added
8
+ - `LangExtract::ModelConfig` now exposes opt-in `structured_output: true` (default `false`) for RubyLLM schema-constrained responses via `with_schema`. Non-boolean values are rejected, and schema-constrained Hash/Array responses are normalized back to JSON for the existing format handler. RubyLLM 1.16.0 or newer is required for this provider path. ([#13](https://github.com/dpaluy/langextract/pull/13))
9
+ - Fuzzy resolver alignment now operates on ordered token subsequences with per-token similarity, aggregate threshold, coverage and density gates, and sentence/negation barriers. Dash/space, standalone comma, apostrophe, and numeric-grouping variants ground symmetrically while preserving original offsets. One-edit tokens of 3–5 characters require another exact token in the same alignment, and every target-side negation must match an equivalent source negation. The default minimum density is 0.50. ([#14](https://github.com/dpaluy/langextract/pull/14))
10
+
11
+ ### Changed
12
+ - Fuzzy alignment responsibilities are split across token-stream, similarity, index, planner, and aligner components. Indexed binary-search lookups replace repeated forward scans, and the historical 4,000-start truncation is no longer applied while candidate ordering and source offsets remain deterministic.
13
+ - Fuzzy planning skips search ranges larger than 20,000 tokens instead of building an unbounded target-by-source table. Exact alignment and preferred chunk-range fuzzy alignment still run; oversized ranges are skipped as a whole rather than truncating later candidate starts.
14
+ - Declared `logger` as a runtime dependency because it is no longer a default gem on Ruby 4.0; clean installations can load LangExtract without requiring applications to add it separately.
15
+
16
+ ### Tests
17
+ - Added resolver grounding regressions for symmetric formatting variants, contextual short typos, coverage/density boundaries, and source- and target-side semantic barriers; complexity and performance guards for indexed alignment; expanded upstream parity fixtures; and provider/factory coverage for schema-constrained output.
18
+
5
19
  ## [0.3.0] - 2026-07-11
6
20
 
7
21
  ### Fixed
data/README.md CHANGED
@@ -18,11 +18,32 @@ Use it when a Ruby or Rails app needs structured LLM output that can be traced b
18
18
  - **Format handling** — JSON and YAML output parsing with strict and lenient modes
19
19
  - **Provider-agnostic** — pluggable LLM providers via [RubyLLM](https://github.com/crmne/ruby_llm)
20
20
 
21
+ ## Why not just use RubyLLM directly?
22
+
23
+ RubyLLM is a provider adapter: it sends a prompt to an LLM and hands you back text. LangExtract is an extraction pipeline that uses RubyLLM as one interchangeable layer at the bottom (`providers/`). The resolver and the rest of the core know nothing about providers.
24
+
25
+ Reach for RubyLLM directly when you want an answer from a model: summarize, answer a question, classify into buckets. You do not need LangExtract's grounding machinery for those.
26
+
27
+ Reach for LangExtract when you need structured spans provably tied to your source text. Used directly, RubyLLM leaves you to build everything below yourself:
28
+
29
+ | Concern | RubyLLM directly | LangExtract gem |
30
+ |---|---|---|
31
+ | **Source grounding** | The model says "Acme Corp appears" but not *where*, and may paraphrase | Exact + token-level fuzzy alignment maps grounded extractions to precise char/token offsets in the source |
32
+ | **Long input** | You chunk manually and hope offsets survive | Sentence-aware chunking with `max_char_buffer`, offsets preserved across chunks |
33
+ | **Output parsing** | You parse JSON/YAML, fenced blocks, and malformed output yourself | `format_handler` with strict/lenient modes and fenced-output extraction |
34
+ | **Prompting** | You write and maintain the prompt | Prompt builder with few-shot `ExampleData` and context-window handling |
35
+ | **Hallucination control** | The model can invent text that isn't in the source | Alignment flags extractions that don't ground to real spans |
36
+ | **Overlaps / dedup** | Your problem | Resolver handles overlapping and duplicate spans |
37
+ | **Visualization** | None | Self-contained HTML highlighting extractions in the source |
38
+ | **Persistence** | None | Lossless JSONL round-trip (`IO.save` / `IO.load`) |
39
+
40
+ In short: RubyLLM gets you an answer from a model; LangExtract gets you structured spans tied to your source. RubyLLM is a dependency the gem deliberately keeps swappable, not the feature.
41
+
21
42
  ## Requirements
22
43
 
23
44
  - Ruby >= 4.0.5
24
45
  - Tested on Ruby 4.0.5
25
- - Optional live inference adapter: `ruby_llm` >= 1.0 when using `LangExtract::Factory.create_model`
46
+ - Optional live inference adapter: `ruby_llm` >= 1.16.0 when using `LangExtract::Factory.create_model`
26
47
 
27
48
  ## Installation
28
49
 
@@ -69,6 +90,18 @@ model = LangExtract::Factory.create_model(
69
90
 
70
91
  If you omit `model`, RubyLLM's configured `default_model` is used.
71
92
 
93
+ Set `structured_output: true` to request RubyLLM's schema-constrained extraction envelope. It defaults to `false`, preserving the normal free-form response path:
94
+
95
+ ```ruby
96
+ model = LangExtract::Factory.create_model(
97
+ LangExtract::ModelConfig.new(
98
+ model: "gpt-4o-mini",
99
+ provider: "openai",
100
+ structured_output: true
101
+ )
102
+ )
103
+ ```
104
+
72
105
  ### Rails
73
106
 
74
107
  Create `config/initializers/langextract.rb`:
@@ -121,6 +154,12 @@ first.char_interval.end_pos
121
154
  first.alignment_status
122
155
  ```
123
156
 
157
+ ### Source alignment
158
+
159
+ LangExtract first searches for exact source text, then falls back to token-level fuzzy alignment. Fuzzy matching preserves token order and applies per-token similarity, coverage, density, and aggregate-threshold gates. Dash/space, standalone comma, apostrophe, and numeric-grouping variants can ground in either direction while retaining original offsets. One-edit tokens of 3–5 characters are accepted only when another aligned token is exact. Every target negation must match an equivalent source negation, and sentence boundaries or source-side negations cannot be crossed. Same-sentence gaps require at least 0.50 matched-token density.
160
+
161
+ Grounded extractions report `exact` or `fuzzy`; when no candidate meets the gates, the extraction reports `ungrounded` without a source interval. Fuzzy planning is limited to 20,000 source tokens per search range to prevent unbounded CPU work on large documents. Exact matching remains available at any size, and the normal extraction pipeline first searches its preferred chunk range (2,000 characters by default). Oversized fuzzy ranges are skipped as a whole rather than truncating later candidates. Adjust the aggregate gate with `fuzzy_threshold:` when calling `LangExtract.extract` (default `0.78`).
162
+
124
163
  ### Document collections
125
164
 
126
165
  ```ruby
@@ -0,0 +1,123 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "data"
4
+ require_relative "fuzzy_alignment_index"
5
+ require_relative "fuzzy_alignment_policy"
6
+ require_relative "fuzzy_alignment_planner"
7
+
8
+ module LangExtract
9
+ module Core
10
+ # Coordinates indexed ordered-subsequence fuzzy alignment and candidate
11
+ # selection. Index construction and dynamic planning live separately.
12
+ class FuzzyAligner
13
+ # Historical limit retained for compatibility. Indexed planning no longer
14
+ # truncates candidate starts.
15
+ MAX_FUZZY_CANDIDATE_STARTS = 4_000
16
+ SubsequenceAlignment = FuzzyAlignmentPlanner::SubsequenceAlignment
17
+
18
+ def initialize(source_tokens:, fuzzy_threshold:, min_coverage:, min_density:, allow_overlaps:,
19
+ alignment_index: nil)
20
+ @source_tokens = source_tokens
21
+ @fuzzy_threshold = fuzzy_threshold
22
+ @allow_overlaps = allow_overlaps
23
+ @alignment_index = alignment_index || FuzzyAlignmentIndex.new(source_tokens)
24
+ @policy = FuzzyAlignmentPolicy.new(source_tokens: source_tokens, fuzzy_threshold: fuzzy_threshold)
25
+ @planner = FuzzyAlignmentPlanner.new(
26
+ source_tokens: source_tokens,
27
+ alignment_index: @alignment_index,
28
+ fuzzy_threshold: fuzzy_threshold,
29
+ min_coverage: min_coverage,
30
+ min_density: min_density
31
+ )
32
+ end
33
+
34
+ # Returns [CharInterval, score] candidates ranked by score, then position.
35
+ def candidates(target_tokens, range, occupied)
36
+ alignments = all_subsequence_alignments(target_tokens)
37
+ return [] if alignments.empty?
38
+
39
+ found = alignments.filter_map do |alignment|
40
+ next unless planner.feasible?(alignment, target_tokens.length)
41
+
42
+ score = policy.score_for(target_tokens, alignment.token_indices)
43
+ next unless score
44
+
45
+ interval = interval_for_alignment(alignment.token_indices, range)
46
+ next unless interval
47
+
48
+ match = [interval, score]
49
+ return [match] if perfect_non_overlapping?(match, occupied)
50
+
51
+ match
52
+ end
53
+
54
+ ranked_unique_candidates(found)
55
+ end
56
+
57
+ private
58
+
59
+ attr_reader :source_tokens, :fuzzy_threshold, :allow_overlaps, :alignment_index, :planner, :policy
60
+
61
+ def all_subsequence_alignments(target_tokens)
62
+ start_flags = Array.new(source_tokens.length, false)
63
+ target_tokens.each do |target_token|
64
+ alignment_index.matching_positions(target_token).indices.each { |idx| start_flags[idx] = true }
65
+ end
66
+ valid_starts = start_flags.each_index.select { |idx| start_flags[idx] }
67
+ return [] if valid_starts.empty?
68
+
69
+ seen = {}
70
+ valid_starts.each_with_object([]) do |start, alignments|
71
+ alignment = align_from_start(target_tokens, start)
72
+ next unless alignment
73
+ next if seen[alignment.token_indices]
74
+
75
+ seen[alignment.token_indices] = true
76
+ alignments << alignment
77
+ end
78
+ end
79
+
80
+ def align_from_start(target_tokens, source_start)
81
+ planner.alignment_for(target_tokens, source_start)
82
+ end
83
+
84
+ def find_token_match(target_token, from_index)
85
+ alignment_index.best_match(target_token, from_index)
86
+ end
87
+
88
+ def norm_source
89
+ alignment_index.normalized_tokens
90
+ end
91
+
92
+ def interval_for_alignment(token_indices, range)
93
+ return nil if token_indices.empty?
94
+
95
+ first_token = source_tokens[token_indices.first]
96
+ last_token = source_tokens[token_indices.last]
97
+ start_pos = [first_token.char_interval.start_pos, range.begin].max
98
+ end_pos = [last_token.char_interval.end_pos, range.end].min
99
+ return nil if start_pos >= end_pos
100
+
101
+ CharInterval.new(start_pos: start_pos, end_pos: end_pos)
102
+ end
103
+
104
+ def perfect_non_overlapping?(match, occupied)
105
+ interval, score = match
106
+ score >= 1.0 && (allow_overlaps || !overlaps_any?(interval, occupied))
107
+ end
108
+
109
+ def ranked_unique_candidates(candidates)
110
+ best_by_interval = candidates.each_with_object({}) do |(interval, score), result|
111
+ key = [interval.start_pos, interval.end_pos]
112
+ result[key] = [interval, score] if result[key].nil? || score > result[key].last
113
+ end
114
+
115
+ best_by_interval.values.sort_by { |interval, score| [-score, interval.start_pos, interval.end_pos] }
116
+ end
117
+
118
+ def overlaps_any?(interval, occupied)
119
+ occupied.any? { |other| interval.overlaps?(other) }
120
+ end
121
+ end
122
+ end
123
+ end
@@ -0,0 +1,81 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "token_similarity"
4
+
5
+ module LangExtract
6
+ module Core
7
+ # Precomputed source-token lookup for fuzzy alignment. Similarity is
8
+ # evaluated once per distinct normalized source text and target token.
9
+ class FuzzyAlignmentIndex
10
+ MatchingIndex = Data.define(:indices, :scores, :best_indices)
11
+
12
+ def initialize(source_tokens)
13
+ @source_tokens = source_tokens
14
+ end
15
+
16
+ def normalized_tokens
17
+ @normalized_tokens ||= source_tokens.map { |token| TokenSimilarity.normalize(token.text) }
18
+ end
19
+
20
+ def matching_positions(target_token)
21
+ @matching_positions_cache ||= {}
22
+ normalized_target = TokenSimilarity.normalize(target_token)
23
+ @matching_positions_cache[normalized_target] ||= build_matching_index(normalized_target)
24
+ end
25
+
26
+ def best_match(target_token, from_index)
27
+ matching = matching_positions(target_token)
28
+ position = matching.indices.bsearch_index { |idx| idx >= from_index }
29
+ position && matching.best_indices[position]
30
+ end
31
+
32
+ private
33
+
34
+ attr_reader :source_tokens
35
+
36
+ def source_index
37
+ @source_index ||= normalized_tokens.each_with_index.with_object({}) do |(normalized_text, idx), index|
38
+ (index[normalized_text] ||= []) << idx
39
+ end
40
+ end
41
+
42
+ def build_matching_index(normalized_target)
43
+ pairs = matching_pairs(normalized_target).sort_by(&:first)
44
+ indices = pairs.map(&:first)
45
+ scores = pairs.map(&:last)
46
+ MatchingIndex.new(
47
+ indices: indices,
48
+ scores: scores,
49
+ best_indices: suffix_best_indices(indices, scores)
50
+ )
51
+ end
52
+
53
+ def matching_pairs(normalized_target)
54
+ source_index.each_with_object([]) do |(normalized_text, indices), matches|
55
+ next unless TokenSimilarity.similar?(normalized_target, normalized_text)
56
+
57
+ similarity = TokenSimilarity.char_similarity(normalized_target, normalized_text)
58
+ indices.each { |idx| matches << [idx, similarity] }
59
+ end
60
+ end
61
+
62
+ def suffix_best_indices(indices, scores)
63
+ best_indices = Array.new(indices.length)
64
+ best_position = nil
65
+ (indices.length - 1).downto(0) do |position|
66
+ best_position = better_score_position(position, best_position, indices, scores)
67
+ best_indices[position] = indices[best_position]
68
+ end
69
+ best_indices
70
+ end
71
+
72
+ def better_score_position(position, best_position, indices, scores)
73
+ return position if best_position.nil?
74
+ return position if scores[position] > scores[best_position]
75
+ return position if scores[position] == scores[best_position] && indices[position] < indices[best_position]
76
+
77
+ best_position
78
+ end
79
+ end
80
+ end
81
+ end
@@ -0,0 +1,239 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "fuzzy_alignment_policy"
4
+ require_relative "token_similarity"
5
+
6
+ module LangExtract
7
+ module Core
8
+ # Plans ordered fuzzy token alignments with coverage, density, score, and
9
+ # semantic-barrier constraints. The suffix table is shared by all starts.
10
+ class FuzzyAlignmentPlanner
11
+ MAX_PATH_ALTERNATIVES_PER_MATCH_COUNT = 2
12
+ SENTENCE_BOUNDARY_PUNCTUATION = /[.!?\u2026\u3002\uff01\uff1f]/u
13
+
14
+ SubsequenceAlignment = Data.define(:matched, :window_size, :token_indices, :score)
15
+ AlignmentPath = Data.define(:matched, :score_sum, :first_idx, :last_idx, :previous, :safe_gap)
16
+
17
+ def initialize(source_tokens:, alignment_index:, fuzzy_threshold:, min_coverage:, min_density:)
18
+ @source_tokens = source_tokens
19
+ @alignment_index = alignment_index
20
+ @fuzzy_threshold = fuzzy_threshold
21
+ @min_coverage = min_coverage
22
+ @min_density = min_density
23
+ end
24
+
25
+ def alignment_for(target_tokens, source_start)
26
+ paths = alignment_table(target_tokens)[source_start]
27
+ preferred_path(paths, target_tokens.length)
28
+ end
29
+
30
+ def feasible?(alignment, target_count)
31
+ return false if target_count.zero?
32
+
33
+ coverage = alignment.matched.to_f / target_count
34
+ density = alignment.matched.to_f / alignment.window_size
35
+
36
+ coverage >= min_coverage &&
37
+ density >= min_density &&
38
+ alignment.score >= fuzzy_threshold &&
39
+ safe_source_gap?(alignment.token_indices)
40
+ end
41
+
42
+ private
43
+
44
+ attr_reader :source_tokens, :alignment_index, :fuzzy_threshold, :min_coverage, :min_density
45
+
46
+ def alignment_table(target_tokens)
47
+ @alignment_table_cache ||= {}
48
+ key = target_tokens.map(&:to_s).freeze
49
+ @alignment_table_cache[key] ||= build_alignment_table(target_tokens)
50
+ end
51
+
52
+ def build_alignment_table(target_tokens)
53
+ next_row = Array.new(source_tokens.length + 1) { [] }
54
+ target_tokens.reverse_each do |target_token|
55
+ next_row = alignment_row(target_token, next_row)
56
+ end
57
+ next_row
58
+ end
59
+
60
+ def alignment_row(target_token, next_row)
61
+ matching = alignment_index.matching_positions(target_token)
62
+ options = alignment_options(matching, next_row)
63
+ best_match = suffix_best_paths(options)
64
+ row = Array.new(source_tokens.length + 1)
65
+ source_tokens.length.downto(0) do |source_position|
66
+ row[source_position] = merge_path_frontiers(best_match[source_position], next_row[source_position])
67
+ end
68
+ row
69
+ end
70
+
71
+ def alignment_options(matching, next_row)
72
+ matching.indices.each_with_index.flat_map do |idx, position|
73
+ suffixes = next_row[idx + 1]
74
+ base = path_with_match(idx, matching.scores[position], nil)
75
+ continuations = suffixes.to_a.flat_map do |suffixes_for_count|
76
+ next [] unless suffixes_for_count
77
+
78
+ suffixes_for_count.filter_map do |suffix|
79
+ next unless suffix.safe_gap && source_gap_safe?(idx, suffix.first_idx)
80
+
81
+ path_with_match(idx, matching.scores[position], suffix)
82
+ end
83
+ end
84
+ [base] + continuations
85
+ end
86
+ end
87
+
88
+ def path_with_match(index, score, suffix)
89
+ AlignmentPath.new(
90
+ matched: 1 + (suffix&.matched || 0),
91
+ score_sum: score + (suffix&.score_sum || 0.0),
92
+ first_idx: index,
93
+ last_idx: suffix ? suffix.last_idx : index,
94
+ previous: suffix,
95
+ safe_gap: suffix.nil? || (suffix.safe_gap && source_gap_safe?(index, suffix.first_idx))
96
+ )
97
+ end
98
+
99
+ def suffix_best_paths(options)
100
+ best_match = Array.new(source_tokens.length + 1) { [] }
101
+ option_position = options.length - 1
102
+ next_frontier = []
103
+ (source_tokens.length - 1).downto(0) do |source_position|
104
+ while option_position >= 0 && options[option_position].first_idx >= source_position
105
+ append_path(best_match[source_position], options[option_position])
106
+ option_position -= 1
107
+ end
108
+
109
+ if barrier_token?(source_position)
110
+ best_match[source_position] = prune_frontier(best_match[source_position])
111
+ next_frontier = []
112
+ else
113
+ best_match[source_position] = merge_path_frontiers(best_match[source_position], next_frontier)
114
+ next_frontier = best_match[source_position]
115
+ end
116
+ end
117
+ best_match
118
+ end
119
+
120
+ def merge_path_frontiers(left, right)
121
+ return right if left.empty?
122
+ return left if right.empty?
123
+
124
+ merged = Array.new([left.length, right.length].max) { [] }
125
+ [left, right].each do |frontier|
126
+ frontier.each_with_index do |paths, count|
127
+ merged[count] = prune_paths(merged[count] + paths.to_a)
128
+ end
129
+ end
130
+ merged
131
+ end
132
+
133
+ def append_path(frontier, path)
134
+ frontier[path.matched] ||= []
135
+ frontier[path.matched] << path
136
+ end
137
+
138
+ def prune_frontier(frontier)
139
+ frontier.map { |paths| prune_paths(paths.to_a) }
140
+ end
141
+
142
+ def prune_paths(paths)
143
+ candidates = paths.uniq { |path| path_signature(path) }
144
+ nondominated = candidates.reject do |candidate|
145
+ candidates.any? do |other|
146
+ other != candidate && dominates_path?(other, candidate)
147
+ end
148
+ end
149
+ return nondominated if nondominated.length <= MAX_PATH_ALTERNATIVES_PER_MATCH_COUNT
150
+
151
+ score_order = nondominated.sort_by { |path| [-path.score_sum, path.last_idx, path.first_idx] }
152
+ compact_order = nondominated.sort_by { |path| [path.last_idx, -path.score_sum, path.first_idx] }
153
+ (score_order.first(MAX_PATH_ALTERNATIVES_PER_MATCH_COUNT / 2) +
154
+ compact_order.first(MAX_PATH_ALTERNATIVES_PER_MATCH_COUNT / 2)).uniq.first(
155
+ MAX_PATH_ALTERNATIVES_PER_MATCH_COUNT
156
+ )
157
+ end
158
+
159
+ def path_signature(path)
160
+ [path.first_idx, path.last_idx, path.score_sum, path.safe_gap]
161
+ end
162
+
163
+ def dominates_path?(left, right)
164
+ left.score_sum >= right.score_sum &&
165
+ left.last_idx <= right.last_idx &&
166
+ left.first_idx <= right.first_idx &&
167
+ (!right.safe_gap || left.safe_gap)
168
+ end
169
+
170
+ def preferred_path(paths, target_count)
171
+ candidates = paths.compact.flat_map { |paths_for_count| paths_for_count }
172
+ return nil if candidates.empty?
173
+
174
+ alignments = candidates.map { |path| subsequence_alignment(path) }
175
+ feasible = alignments.select { |alignment| feasible?(alignment, target_count) }
176
+ return alignments.max_by { |alignment| fallback_alignment_priority(alignment) } if feasible.empty?
177
+
178
+ feasible.max_by { |alignment| alignment_priority(alignment) }
179
+ end
180
+
181
+ def subsequence_alignment(path)
182
+ SubsequenceAlignment.new(
183
+ matched: path.matched,
184
+ window_size: path.last_idx - path.first_idx + 1,
185
+ token_indices: path_indices(path),
186
+ score: path.score_sum / path.matched
187
+ )
188
+ end
189
+
190
+ def path_indices(path)
191
+ indices = Array.new(path.matched)
192
+ current = path
193
+ path.matched.times do |position|
194
+ indices[position] = current.first_idx
195
+ current = current.previous
196
+ end
197
+ indices
198
+ end
199
+
200
+ def alignment_priority(alignment)
201
+ [alignment.score, alignment.matched, -alignment.token_indices.first, -alignment.window_size]
202
+ end
203
+
204
+ def fallback_alignment_priority(alignment)
205
+ [alignment.matched, alignment.score, -alignment.token_indices.first, -alignment.window_size]
206
+ end
207
+
208
+ def safe_source_gap?(token_indices)
209
+ token_indices.each_cons(2).all? do |left_idx, right_idx|
210
+ source_gap_safe?(left_idx, right_idx)
211
+ end
212
+ end
213
+
214
+ def source_gap_safe?(left_idx, right_idx)
215
+ barrier_prefix[right_idx] == barrier_prefix[left_idx + 1]
216
+ end
217
+
218
+ def barrier_token?(index)
219
+ token = source_tokens[index]
220
+ sentence_boundary_punctuation?(token.text) || negation_token?(token.text)
221
+ end
222
+
223
+ def barrier_prefix
224
+ @barrier_prefix ||= source_tokens.each_with_object([0]) do |token, prefix|
225
+ barrier = sentence_boundary_punctuation?(token.text) || negation_token?(token.text)
226
+ prefix << (prefix.last + (barrier ? 1 : 0))
227
+ end
228
+ end
229
+
230
+ def sentence_boundary_punctuation?(text)
231
+ text.match?(SENTENCE_BOUNDARY_PUNCTUATION)
232
+ end
233
+
234
+ def negation_token?(text)
235
+ FuzzyAlignmentPolicy.negation_token?(text)
236
+ end
237
+ end
238
+ end
239
+ end
@@ -0,0 +1,124 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "token_similarity"
4
+
5
+ module LangExtract
6
+ module Core
7
+ # Validates semantic constraints that depend on the target-to-source token
8
+ # mapping rather than only the selected source interval.
9
+ class FuzzyAlignmentPolicy
10
+ NEGATION_TOKENS = %w[
11
+ aint arent cannot cant couldnt deny denied denies denying didnt doesnt
12
+ dont hadnt hardly hasnt havent isnt mustnt neither neednt never no none
13
+ not shouldnt shant wasnt werent without wont wouldnt
14
+ ].freeze
15
+
16
+ MappingState = Data.define(
17
+ :last_target_index,
18
+ :score_sum,
19
+ :has_exact_match,
20
+ :has_short_typo,
21
+ :negation_mask
22
+ )
23
+
24
+ def self.negation_token?(text)
25
+ NEGATION_TOKENS.include?(TokenSimilarity.normalize(text))
26
+ end
27
+
28
+ def initialize(source_tokens:, fuzzy_threshold:)
29
+ @source_tokens = source_tokens
30
+ @fuzzy_threshold = fuzzy_threshold
31
+ end
32
+
33
+ # Returns the best semantically valid average score, or nil when the
34
+ # selected source tokens cannot be mapped safely to the target tokens.
35
+ def score_for(target_tokens, source_indices)
36
+ return nil if target_tokens.empty? || source_indices.empty?
37
+
38
+ states = [initial_state]
39
+ source_indices.each do |source_index|
40
+ states = advance_states(states, target_tokens, source_tokens.fetch(source_index).text)
41
+ return nil if states.empty?
42
+ end
43
+
44
+ best_valid_score(states, required_negation_mask(target_tokens), source_indices.length)
45
+ end
46
+
47
+ private
48
+
49
+ attr_reader :source_tokens, :fuzzy_threshold
50
+
51
+ def initial_state
52
+ MappingState.new(
53
+ last_target_index: -1,
54
+ score_sum: 0.0,
55
+ has_exact_match: false,
56
+ has_short_typo: false,
57
+ negation_mask: 0
58
+ )
59
+ end
60
+
61
+ def best_valid_score(states, required_negations, matched_count)
62
+ states.filter_map do |state|
63
+ next unless valid_mapping?(state, required_negations, matched_count)
64
+
65
+ score = state.score_sum / matched_count
66
+ score if score >= fuzzy_threshold
67
+ end.max
68
+ end
69
+
70
+ def valid_mapping?(state, required_negations, matched_count)
71
+ return false unless state.negation_mask.allbits?(required_negations)
72
+ return false if state.has_short_typo && (!state.has_exact_match || matched_count < 2)
73
+
74
+ true
75
+ end
76
+
77
+ def advance_states(states, target_tokens, source_text)
78
+ best_by_signature = {}
79
+ states.each do |state|
80
+ ((state.last_target_index + 1)...target_tokens.length).each do |target_index|
81
+ target_text = target_tokens[target_index]
82
+ next unless TokenSimilarity.similar?(target_text, source_text)
83
+
84
+ candidate = extend_state(state, target_text, target_index, source_text)
85
+ signature = state_signature(candidate)
86
+ current = best_by_signature[signature]
87
+ best_by_signature[signature] = candidate if current.nil? || candidate.score_sum > current.score_sum
88
+ end
89
+ end
90
+ best_by_signature.values
91
+ end
92
+
93
+ def extend_state(state, target_text, target_index, source_text)
94
+ target = TokenSimilarity.normalize(target_text)
95
+ source = TokenSimilarity.normalize(source_text)
96
+ exact = target == source
97
+ negation_mask = state.negation_mask
98
+ negation_mask |= (1 << target_index) if exact && negation_token?(target)
99
+
100
+ MappingState.new(
101
+ last_target_index: target_index,
102
+ score_sum: state.score_sum + TokenSimilarity.char_similarity(target, source),
103
+ has_exact_match: state.has_exact_match || exact,
104
+ has_short_typo: state.has_short_typo || TokenSimilarity.short_typo?(target, source),
105
+ negation_mask: negation_mask
106
+ )
107
+ end
108
+
109
+ def state_signature(state)
110
+ [state.last_target_index, state.has_exact_match, state.has_short_typo, state.negation_mask]
111
+ end
112
+
113
+ def required_negation_mask(target_tokens)
114
+ target_tokens.each_with_index.reduce(0) do |mask, (token, index)|
115
+ negation_token?(token) ? mask | (1 << index) : mask
116
+ end
117
+ end
118
+
119
+ def negation_token?(text)
120
+ self.class.negation_token?(text)
121
+ end
122
+ end
123
+ end
124
+ end