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 ADDED
@@ -0,0 +1,7 @@
1
+ """manyhands: cross-model OCR consensus for handwritten and historical pages."""
2
+
3
+ from __future__ import annotations
4
+
5
+ __version__ = "1.0.0"
6
+
7
+ __all__ = ["__version__"]
manyhands/__main__.py ADDED
@@ -0,0 +1,5 @@
1
+ """Entry point for ``python -m manyhands``."""
2
+
3
+ from manyhands.cli import app
4
+
5
+ app()
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