manyhands 1.0.0__py3-none-any.whl
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.
- manyhands/__init__.py +7 -0
- manyhands/__main__.py +5 -0
- manyhands/align.py +209 -0
- manyhands/backends.py +621 -0
- manyhands/cli.py +526 -0
- manyhands/evaluate.py +446 -0
- manyhands/glossary.py +95 -0
- manyhands/layout.py +268 -0
- manyhands/pipeline.py +440 -0
- manyhands/report.py +261 -0
- manyhands/templates/index.html.j2 +51 -0
- manyhands/templates/report.html.j2 +268 -0
- manyhands/text.py +120 -0
- manyhands/vote.py +232 -0
- manyhands-1.0.0.dist-info/METADATA +596 -0
- manyhands-1.0.0.dist-info/RECORD +19 -0
- manyhands-1.0.0.dist-info/WHEEL +4 -0
- manyhands-1.0.0.dist-info/entry_points.txt +2 -0
- manyhands-1.0.0.dist-info/licenses/LICENSE +21 -0
manyhands/__init__.py
ADDED
manyhands/__main__.py
ADDED
manyhands/align.py
ADDED
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
"""Anchor-based progressive multiple alignment of N token streams, ROVER style.
|
|
2
|
+
|
|
3
|
+
The lattice is built from token keys that occur exactly once in every stream. Those
|
|
4
|
+
anchors are certain, so they become single-token columns and cut the page into
|
|
5
|
+
independent gaps. Each gap is aligned recursively: a key that was ambiguous across the
|
|
6
|
+
whole page is often unique inside a twenty-token gap, so the anchor pass keeps paying
|
|
7
|
+
off as it descends. When a gap has no anchors left it falls back to progressive
|
|
8
|
+
pairwise alignment against the longest stream, which is where null tokens appear.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from collections.abc import Sequence
|
|
14
|
+
from dataclasses import dataclass, field
|
|
15
|
+
from difflib import SequenceMatcher
|
|
16
|
+
|
|
17
|
+
from manyhands.text import Token
|
|
18
|
+
|
|
19
|
+
# Recursion is bounded by the anchor pass removing at least one token per stream, but a
|
|
20
|
+
# pathological page should still not blow the Python stack.
|
|
21
|
+
_MAX_DEPTH = 64
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass(slots=True)
|
|
25
|
+
class Column:
|
|
26
|
+
"""One aligned slot: at most one token from each stream.
|
|
27
|
+
|
|
28
|
+
:param entries: Stream index to the token that stream contributed, if any.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
entries: dict[int, Token] = field(default_factory=dict)
|
|
32
|
+
|
|
33
|
+
def token(self, stream: int) -> Token | None:
|
|
34
|
+
"""Return the token stream ``stream`` contributed, or ``None`` for a null."""
|
|
35
|
+
return self.entries.get(stream)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def align(streams: Sequence[Sequence[Token]]) -> list[Column]:
|
|
39
|
+
"""Align N token streams into a single ordered list of columns.
|
|
40
|
+
|
|
41
|
+
:param streams: One token stream per backend, in backend order.
|
|
42
|
+
:returns: Columns in reading order; every stream appears at most once per column.
|
|
43
|
+
"""
|
|
44
|
+
if not streams:
|
|
45
|
+
return []
|
|
46
|
+
return _align_region([list(stream) for stream in streams], depth=0)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _align_region(streams: list[list[Token]], *, depth: int) -> list[Column]:
|
|
50
|
+
if all(not stream for stream in streams):
|
|
51
|
+
return []
|
|
52
|
+
if depth < _MAX_DEPTH:
|
|
53
|
+
chain = _anchor_chain(streams)
|
|
54
|
+
if chain:
|
|
55
|
+
return _split_on_anchors(streams, chain, depth=depth)
|
|
56
|
+
return _align_gap(streams)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _anchor_chain(streams: list[list[Token]]) -> list[tuple[int, ...]]:
|
|
60
|
+
"""Return anchor positions as a chain of per-stream index tuples, increasing in all streams."""
|
|
61
|
+
positions = _unique_shared_positions(streams)
|
|
62
|
+
if not positions:
|
|
63
|
+
return []
|
|
64
|
+
|
|
65
|
+
candidates = sorted(positions.values())
|
|
66
|
+
if all(
|
|
67
|
+
all(a < b for a, b in zip(earlier, later, strict=True))
|
|
68
|
+
for earlier, later in zip(candidates, candidates[1:], strict=False)
|
|
69
|
+
):
|
|
70
|
+
# Backends normally return the page in the same order, so every anchor already
|
|
71
|
+
# advances in every stream and the quadratic search below would only confirm it.
|
|
72
|
+
return candidates
|
|
73
|
+
|
|
74
|
+
# Longest chain that advances in every stream at once: an N-dimensional LIS.
|
|
75
|
+
best = [1] * len(candidates)
|
|
76
|
+
previous: list[int | None] = [None] * len(candidates)
|
|
77
|
+
for i, later in enumerate(candidates):
|
|
78
|
+
for j, earlier in enumerate(candidates[:i]):
|
|
79
|
+
if best[j] + 1 > best[i] and all(a < b for a, b in zip(earlier, later, strict=True)):
|
|
80
|
+
best[i] = best[j] + 1
|
|
81
|
+
previous[i] = j
|
|
82
|
+
end = max(range(len(candidates)), key=lambda i: best[i])
|
|
83
|
+
chain: list[tuple[int, ...]] = []
|
|
84
|
+
cursor: int | None = end
|
|
85
|
+
while cursor is not None:
|
|
86
|
+
chain.append(candidates[cursor])
|
|
87
|
+
cursor = previous[cursor]
|
|
88
|
+
chain.reverse()
|
|
89
|
+
return chain
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _unique_shared_positions(streams: list[list[Token]]) -> dict[str, tuple[int, ...]]:
|
|
93
|
+
"""Return keys that occur exactly once in every stream, mapped to their positions."""
|
|
94
|
+
per_stream: list[dict[str, int]] = []
|
|
95
|
+
for stream in streams:
|
|
96
|
+
seen: dict[str, int] = {}
|
|
97
|
+
duplicated: set[str] = set()
|
|
98
|
+
for index, token in enumerate(stream):
|
|
99
|
+
key = token.key
|
|
100
|
+
if not key or key in seen:
|
|
101
|
+
duplicated.add(key)
|
|
102
|
+
else:
|
|
103
|
+
seen[key] = index
|
|
104
|
+
for key in duplicated:
|
|
105
|
+
seen.pop(key, None)
|
|
106
|
+
per_stream.append(seen)
|
|
107
|
+
|
|
108
|
+
if not per_stream:
|
|
109
|
+
return {}
|
|
110
|
+
shared = set(per_stream[0])
|
|
111
|
+
for seen in per_stream[1:]:
|
|
112
|
+
shared &= set(seen)
|
|
113
|
+
return {key: tuple(seen[key] for seen in per_stream) for key in shared}
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _split_on_anchors(
|
|
117
|
+
streams: list[list[Token]],
|
|
118
|
+
chain: list[tuple[int, ...]],
|
|
119
|
+
*,
|
|
120
|
+
depth: int,
|
|
121
|
+
) -> list[Column]:
|
|
122
|
+
columns: list[Column] = []
|
|
123
|
+
cursors = [0] * len(streams)
|
|
124
|
+
for anchor in chain:
|
|
125
|
+
gap = [streams[s][cursors[s] : anchor[s]] for s in range(len(streams))]
|
|
126
|
+
columns.extend(_align_region(gap, depth=depth + 1))
|
|
127
|
+
columns.append(Column(entries={s: streams[s][anchor[s]] for s in range(len(streams))}))
|
|
128
|
+
cursors = [index + 1 for index in anchor]
|
|
129
|
+
tail = [streams[s][cursors[s] :] for s in range(len(streams))]
|
|
130
|
+
columns.extend(_align_region(tail, depth=depth + 1))
|
|
131
|
+
return columns
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _align_gap(streams: list[list[Token]]) -> list[Column]:
|
|
135
|
+
"""Align an anchor-free region progressively against the longest stream."""
|
|
136
|
+
pivot_index = max(range(len(streams)), key=lambda s: len(streams[s]))
|
|
137
|
+
pivot = streams[pivot_index]
|
|
138
|
+
if not pivot:
|
|
139
|
+
return []
|
|
140
|
+
|
|
141
|
+
pivot_keys = [token.key for token in pivot]
|
|
142
|
+
anchored: list[Column] = [Column(entries={pivot_index: token}) for token in pivot]
|
|
143
|
+
# gaps[i] holds the columns that sit before pivot token i; gaps[len(pivot)] is the tail.
|
|
144
|
+
gaps: list[list[Column]] = [[] for _ in range(len(pivot) + 1)]
|
|
145
|
+
|
|
146
|
+
for stream_index, stream in enumerate(streams):
|
|
147
|
+
if stream_index == pivot_index:
|
|
148
|
+
continue
|
|
149
|
+
_merge_stream(stream_index, stream, pivot_keys, anchored, gaps)
|
|
150
|
+
|
|
151
|
+
columns: list[Column] = []
|
|
152
|
+
for i, column in enumerate(anchored):
|
|
153
|
+
columns.extend(gaps[i])
|
|
154
|
+
columns.append(column)
|
|
155
|
+
columns.extend(gaps[len(pivot)])
|
|
156
|
+
return [column for column in columns if column.entries]
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _merge_stream(
|
|
160
|
+
stream_index: int,
|
|
161
|
+
stream: list[Token],
|
|
162
|
+
pivot_keys: list[str],
|
|
163
|
+
anchored: list[Column],
|
|
164
|
+
gaps: list[list[Column]],
|
|
165
|
+
) -> None:
|
|
166
|
+
keys = [token.key for token in stream]
|
|
167
|
+
matcher = SequenceMatcher(a=pivot_keys, b=keys, autojunk=False)
|
|
168
|
+
for tag, i1, i2, j1, j2 in matcher.get_opcodes():
|
|
169
|
+
if tag == "equal":
|
|
170
|
+
for offset in range(i2 - i1):
|
|
171
|
+
anchored[i1 + offset].entries[stream_index] = stream[j1 + offset]
|
|
172
|
+
elif tag == "replace":
|
|
173
|
+
paired = min(i2 - i1, j2 - j1)
|
|
174
|
+
for offset in range(paired):
|
|
175
|
+
anchored[i1 + offset].entries[stream_index] = stream[j1 + offset]
|
|
176
|
+
if j2 - j1 > paired:
|
|
177
|
+
_place_in_gap(gaps[i2], stream_index, stream[j1 + paired : j2])
|
|
178
|
+
elif tag == "insert":
|
|
179
|
+
_place_in_gap(gaps[i1], stream_index, stream[j1:j2])
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def _place_in_gap(gap: list[Column], stream_index: int, tokens: list[Token]) -> None:
|
|
183
|
+
"""Add tokens to a gap, sharing columns with the other streams that landed there.
|
|
184
|
+
|
|
185
|
+
Two streams inserting at the same site are reading the same piece of the page, so
|
|
186
|
+
their tokens belong in the same column and become competing candidates. A matching
|
|
187
|
+
key wins the column outright; otherwise the next free column in order takes it.
|
|
188
|
+
"""
|
|
189
|
+
cursor = 0
|
|
190
|
+
for token in tokens:
|
|
191
|
+
free = [
|
|
192
|
+
position
|
|
193
|
+
for position in range(cursor, len(gap))
|
|
194
|
+
if stream_index not in gap[position].entries
|
|
195
|
+
]
|
|
196
|
+
slot = next(
|
|
197
|
+
(
|
|
198
|
+
position
|
|
199
|
+
for position in free
|
|
200
|
+
if any(other.key == token.key for other in gap[position].entries.values())
|
|
201
|
+
),
|
|
202
|
+
free[0] if free else None,
|
|
203
|
+
)
|
|
204
|
+
if slot is None:
|
|
205
|
+
gap.append(Column(entries={stream_index: token}))
|
|
206
|
+
cursor = len(gap)
|
|
207
|
+
else:
|
|
208
|
+
gap[slot].entries[stream_index] = token
|
|
209
|
+
cursor = slot + 1
|