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 +4 -4
- data/CHANGELOG.md +14 -0
- data/README.md +40 -1
- data/lib/langextract/core/fuzzy_aligner.rb +123 -0
- data/lib/langextract/core/fuzzy_alignment_index.rb +81 -0
- data/lib/langextract/core/fuzzy_alignment_planner.rb +239 -0
- data/lib/langextract/core/fuzzy_alignment_policy.rb +124 -0
- data/lib/langextract/core/fuzzy_token_stream.rb +113 -0
- data/lib/langextract/core/resolver.rb +72 -132
- data/lib/langextract/core/token_similarity.rb +152 -0
- data/lib/langextract/factory.rb +8 -2
- data/lib/langextract/providers/ruby_llm.rb +32 -2
- data/lib/langextract/version.rb +1 -1
- metadata +22 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 36a0e06e39f5d20e93edcfb3ff08982019bf38f350b13145a850667763e10dae
|
|
4
|
+
data.tar.gz: b6249cecfd2b08bc6e98c1806df03b661336d9731638994468b92fdd58a09894
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|