11from __future__ import annotations
22
3- from collections .abc import Iterable
3+ import itertools
4+ from collections .abc import Iterable , Iterator
45from typing import Any , cast
56
67from hyperbase .constants import ATOM_ENCODE_TABLE
@@ -130,30 +131,123 @@ def _collect_positions(tok_pos: Hyperedge) -> list[int]:
130131 return positions
131132
132133
133- def _rebuild_with_text (
134+ def _compute_token_offsets (
135+ tokens : list [str ], text : str
136+ ) -> list [tuple [int , int ] | None ]:
137+ """Cursor-scan ``text`` for each token in order, returning per-token
138+ (start, end) byte offsets. Tokens that can't be located after the running
139+ cursor get ``None`` and the cursor is left unchanged so a single failed
140+ token does not poison the rest of the sentence.
141+ """
142+ spans : list [tuple [int , int ] | None ] = []
143+ cursor = 0
144+ for tok in tokens :
145+ idx = text .find (tok , cursor )
146+ if idx < 0 :
147+ spans .append (None )
148+ else :
149+ spans .append ((idx , idx + len (tok )))
150+ cursor = idx + len (tok )
151+ return spans
152+
153+
154+ def _rebuild_with_metadata (
134155 edge : Hyperedge ,
135156 tok_pos : Hyperedge ,
136157 tokens : list [str ],
158+ text : str ,
159+ offsets : list [tuple [int , int ] | None ],
160+ claimed : frozenset [int ],
137161) -> Hyperedge :
138- """Recursively rebuild an edge, assigning text from tokens and tok_pos."""
162+ """Recursively rebuild an edge, populating per-atom tok_pos/text_span and
163+ per-non-atom continuity-aware ``text``. ``claimed`` is the global set of
164+ token positions actually mapped to atoms anywhere in the loaded root.
165+ """
139166 if edge .atom :
140167 atom = cast (Atom , edge )
141168 pos = int (str (tok_pos ))
142- text = tokens [pos ] if pos >= 0 else None
143- return Atom (str (atom ), atom .parens , text = text )
169+ if pos < 0 :
170+ return Atom (str (atom ), atom .parens )
171+ span = offsets [pos ] if 0 <= pos < len (offsets ) else None
172+ atom_text = (
173+ text [span [0 ] : span [1 ]]
174+ if span is not None
175+ else (tokens [pos ] if 0 <= pos < len (tokens ) else None )
176+ )
177+ return Atom (str (atom ), atom .parens , text = atom_text , tok_pos = pos , text_span = span )
144178 else :
145179 new_children = tuple (
146- _rebuild_with_text (sub_edge , sub_tok_pos , tokens )
180+ _rebuild_with_metadata (
181+ sub_edge , sub_tok_pos , tokens , text , offsets , claimed
182+ )
147183 for sub_edge , sub_tok_pos in zip (edge , tok_pos , strict = False )
148184 )
149- positions = _collect_positions (tok_pos )
150- if positions :
151- min_pos = min (positions )
152- max_pos = max (positions )
153- text = " " .join (tokens [min_pos : max_pos + 1 ])
185+ sub_text = _derive_subedge_text (new_children , text , tokens , offsets , claimed )
186+ return Hyperedge (new_children , text = sub_text )
187+
188+
189+ def _derive_subedge_text (
190+ children : tuple [Hyperedge , ...],
191+ root_text : str ,
192+ root_tokens : list [str ],
193+ offsets : list [tuple [int , int ] | None ],
194+ claimed : frozenset [int ],
195+ ) -> str | None :
196+ """Continuity-aware text derivation for a non-atomic edge.
197+
198+ Walks the descendant atoms, uses their ``tok_pos`` to identify which
199+ source positions the sub-edge references (``used``), and slices the
200+ root's ``text`` per contiguous run. A run is broken only by a position
201+ in ``claimed`` (a real atom anywhere in the root) that is not in
202+ ``used`` -- synthetic atoms and unclaimed tokens (e.g. punctuation that
203+ no atom maps to) keep the run continuous.
204+ """
205+ used_positions : set [int ] = set ()
206+ for a in _walk_atoms (children ):
207+ pos = cast (Atom , a ).tok_pos
208+ if pos is not None :
209+ used_positions .add (pos )
210+ used : list [int ] = sorted (used_positions )
211+ if not used :
212+ return None
213+
214+ span_by_pos : dict [int , tuple [int , int ] | None ] = {}
215+ for a in _walk_atoms (children ):
216+ atom = cast (Atom , a )
217+ if atom .tok_pos is not None and atom .tok_pos not in span_by_pos :
218+ span_by_pos [atom .tok_pos ] = atom .text_span
219+
220+ # Group used positions into runs, splitting whenever a claimed-but-unused
221+ # position falls between two consecutive used positions.
222+ used_set = set (used )
223+ runs : list [list [int ]] = [[used [0 ]]]
224+ for prev , curr in itertools .pairwise (used ):
225+ broken = any (prev < p < curr and p not in used_set for p in claimed )
226+ if broken :
227+ runs .append ([curr ])
228+ else :
229+ runs [- 1 ].append (curr )
230+
231+ slices : list [str ] = []
232+ for run in runs :
233+ first_span = span_by_pos .get (run [0 ])
234+ last_span = span_by_pos .get (run [- 1 ])
235+ if first_span is not None and last_span is not None :
236+ slices .append (root_text [first_span [0 ] : last_span [1 ]])
154237 else :
155- text = None
156- return Hyperedge (new_children , text = text )
238+ # Local fallback: token-join for this run.
239+ slices .append (
240+ " " .join (root_tokens [p ] for p in run if 0 <= p < len (root_tokens ))
241+ )
242+ return " " .join (slices )
243+
244+
245+ def _walk_atoms (edges : tuple [Hyperedge , ...]) -> Iterator [Hyperedge ]:
246+ for e in edges :
247+ if e .atom :
248+ yield e
249+ else :
250+ yield from _walk_atoms (tuple (e ))
157251
158252
159253def hedge (
@@ -162,8 +256,23 @@ def hedge(
162256 """Create a hyperedge."""
163257 if isinstance (source , ParseResult ):
164258 _source = source
165- edge = _rebuild_with_text (_source .edge , _source .tok_pos , _source .tokens )
166- object .__setattr__ (edge , "text" , _source .text )
259+ offsets = _compute_token_offsets (_source .tokens , _source .text )
260+ # Compute claimed = all token positions referenced by atoms in the
261+ # original edge tree. Used to detect real (non-synthetic, non-padding)
262+ # discontinuities in sub-edge text derivation.
263+ claimed = frozenset (_collect_positions (_source .tok_pos ))
264+ edge = _rebuild_with_metadata (
265+ _source .edge ,
266+ _source .tok_pos ,
267+ _source .tokens ,
268+ _source .text ,
269+ offsets ,
270+ claimed ,
271+ )
272+ # Override the root's text with the verbatim original sentence and
273+ # store tokens on the root for any later derivation.
274+ object .__setattr__ (edge , "_text" , _source .text )
275+ object .__setattr__ (edge , "tokens" , tuple (_source .tokens ))
167276 return edge
168277 if type (source ) in {tuple , list }:
169278 _source = cast (Iterable , source )
0 commit comments