Skip to content

Commit c2a1956

Browse files
committed
root _text preservation/inference on transforms; atoms with the same root keep tok_pos/text_span
1 parent 6929085 commit c2a1956

3 files changed

Lines changed: 229 additions & 21 deletions

File tree

src/hyperbase/hyperedge.py

Lines changed: 19 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -233,9 +233,9 @@ def replace_atom(
233233
unique -- match only the exact same instance of the atom, i.e.
234234
UniqueAtom(self) == UniqueAtom(old) (default: False)
235235
"""
236-
from hyperbase.transforms import replace_atom
236+
from hyperbase.transforms import _propagate_root_text, replace_atom
237237

238-
return replace_atom(self, old, new, unique=unique)
238+
return _propagate_root_text(self, replace_atom(self, old, new, unique=unique))
239239

240240
def simplify(self, subtypes: bool = False, namespaces: bool = False) -> Hyperedge:
241241
"""Returns a version of the edge with simplified atoms.
@@ -244,9 +244,11 @@ def simplify(self, subtypes: bool = False, namespaces: bool = False) -> Hyperedg
244244
subtypes -- include subtypes (default: True).
245245
namespaces -- include namespaces (default: True).
246246
"""
247-
from hyperbase.transforms import simplify
247+
from hyperbase.transforms import _propagate_root_text, simplify
248248

249-
return simplify(self, subtypes=subtypes, namespaces=namespaces)
249+
return _propagate_root_text(
250+
self, simplify(self, subtypes=subtypes, namespaces=namespaces)
251+
)
250252

251253
def transform(
252254
self,
@@ -255,9 +257,12 @@ def transform(
255257
recursive: bool = True,
256258
) -> Hyperedge:
257259
"""Pattern-driven rewrite. See ``hyperbase.transforms.transform``."""
258-
from hyperbase.transforms import transform
260+
from hyperbase.transforms import _propagate_root_text, transform
259261

260-
return transform(self, origin_pattern, target_pattern, recursive=recursive)
262+
return _propagate_root_text(
263+
self,
264+
transform(self, origin_pattern, target_pattern, recursive=recursive),
265+
)
261266

262267
def type(self) -> str:
263268
"""Returns the type of this edge as a string.
@@ -376,27 +381,27 @@ def replace_argroles(self, argroles: str | None) -> Hyperedge:
376381
"""Returns an edge with the argroles of the connector atom replaced
377382
with the provided string.
378383
Returns same edge if the atom does not contain a role part."""
379-
from hyperbase.transforms import replace_argroles
384+
from hyperbase.transforms import _propagate_root_text, replace_argroles
380385

381-
return replace_argroles(self, argroles)
386+
return _propagate_root_text(self, replace_argroles(self, argroles))
382387

383388
def _insert_argrole(self, argrole: str, pos: int) -> Hyperedge:
384389
"""Returns an edge with the given argrole inserted at the specified
385390
position in the argroles of the connector atom.
386391
Same restrictions as in replace_argroles() apply."""
387-
from hyperbase.transforms import insert_argrole
392+
from hyperbase.transforms import _propagate_root_text, insert_argrole
388393

389-
return insert_argrole(self, argrole, pos)
394+
return _propagate_root_text(self, insert_argrole(self, argrole, pos))
390395

391396
def add_argument(
392397
self, edge: Hyperedge, argrole: str, pos: int | None = None
393398
) -> Hyperedge:
394399
"""Returns a new edge with the provided edge and its argroles inserted
395400
at the specified position. If pos is not provided, the argument is
396401
appended at the end."""
397-
from hyperbase.transforms import add_argument
402+
from hyperbase.transforms import _propagate_root_text, add_argument
398403

399-
return add_argument(self, edge, argrole, pos)
404+
return _propagate_root_text(self, add_argument(self, edge, argrole, pos))
400405

401406
def arguments_with_role(self, argrole: str) -> list[Hyperedge]:
402407
"""Returns the list of edges with the given argument role."""
@@ -418,9 +423,9 @@ def check_correctness(self) -> dict[Hyperedge, list[tuple[str, str]]]:
418423
return check_correctness(self)
419424

420425
def normalise(self) -> Hyperedge:
421-
from hyperbase.transforms import normalise
426+
from hyperbase.transforms import _propagate_root_text, normalise
422427

423-
return normalise(self)
428+
return _propagate_root_text(self, normalise(self))
424429

425430
############
426431
# patterns #

src/hyperbase/transforms.py

Lines changed: 81 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -75,19 +75,44 @@ def replace_atom(
7575
) -> Hyperedge:
7676
"""Return a copy with every instance of *old* replaced by *new*."""
7777
if edge.atom:
78+
edge_atom = cast(Atom, edge)
7879
if unique:
79-
if UniqueAtom(edge) == UniqueAtom(old): # type: ignore[arg-type]
80-
return new
80+
matched = UniqueAtom(edge_atom) == UniqueAtom(old) # type: ignore[arg-type]
8181
else:
82-
if edge == old:
83-
return new
82+
matched = edge_atom == old
83+
if matched:
84+
return _maybe_inherit_atom_metadata(new, edge_atom)
8485
return edge
8586
else:
8687
return Hyperedge(
8788
tuple(replace_atom(item, old, new, unique=unique) for item in edge)
8889
)
8990

9091

92+
def _maybe_inherit_atom_metadata(new: Hyperedge, source: Atom) -> Hyperedge:
93+
"""If *new* is an atom with the same root as *source* and carries no
94+
source-position metadata of its own, return a copy that inherits
95+
``tok_pos``/``text_span``/``text`` from *source*. Otherwise *new* is
96+
returned unchanged.
97+
"""
98+
if not new.atom:
99+
return new
100+
new_atom = cast(Atom, new)
101+
if new_atom.tok_pos is not None or new_atom.text_span is not None:
102+
return new
103+
if new_atom.root() != source.root():
104+
return new
105+
if source.tok_pos is None and source.text_span is None and source._text is None:
106+
return new
107+
return Atom(
108+
new_atom.atom_str,
109+
new_atom.parens,
110+
text=source._text,
111+
tok_pos=source.tok_pos,
112+
text_span=source.text_span,
113+
)
114+
115+
91116
def replace_argroles(edge: Hyperedge, argroles: str | None) -> Hyperedge:
92117
"""Return a copy with the connector's argument roles replaced."""
93118
if edge.atom:
@@ -328,18 +353,21 @@ def _instantiate(
328353

329354

330355
def _attach_metadata_from_original(target_atom: Atom, original: Hyperedge) -> Atom:
331-
"""If ``target_atom`` matches an atom in ``original``, return a copy with
332-
that atom's tok_pos / text_span. Otherwise return ``target_atom`` as-is.
356+
"""If an atom with the same root as ``target_atom`` exists in ``original``,
357+
return a copy that inherits its ``tok_pos``/``text_span``/``text``.
358+
Otherwise return ``target_atom`` as-is.
333359
"""
334360
if target_atom.tok_pos is not None or target_atom.text_span is not None:
335361
return target_atom
362+
target_root = target_atom.root()
336363
for atom in _walk_origin_atoms(original):
337-
if atom == target_atom and (
364+
if atom.root() == target_root and (
338365
atom.tok_pos is not None or atom.text_span is not None
339366
):
340367
return Atom(
341368
target_atom.atom_str,
342369
target_atom.parens,
370+
text=atom._text,
343371
tok_pos=atom.tok_pos,
344372
text_span=atom.text_span,
345373
)
@@ -374,6 +402,7 @@ def _substitute_var_atom(
374402
new_parts = [binding_atom.root(), *target_parts[1:]]
375403
return Atom(
376404
"/".join(p for p in new_parts if p),
405+
text=binding_atom._text,
377406
tok_pos=binding_atom.tok_pos,
378407
text_span=binding_atom.text_span,
379408
)
@@ -402,6 +431,7 @@ def _substitute_var_atom(
402431
if inner.type() == binding.type():
403432
new_inner = Atom(
404433
"/".join(p for p in [inner.root(), *target_parts[1:]] if p),
434+
text=inner._text,
405435
tok_pos=inner.tok_pos,
406436
text_span=inner.text_span,
407437
)
@@ -463,3 +493,47 @@ def _consumed_arg_indices(
463493
consumed.add(i)
464494
break
465495
return consumed
496+
497+
498+
def _propagate_root_text(original: Hyperedge, result: Hyperedge) -> Hyperedge:
499+
"""Carry the original root's source-text fingerprint onto a transform's
500+
result.
501+
502+
Mutates ``result``'s ``_text``/``tokens`` in place (safe: the result is
503+
freshly constructed by the transform). When the original carries no
504+
source text or when ``result is original``, the result is returned
505+
unchanged. When the set of atom roots is unchanged, ``_text`` is
506+
preserved verbatim. Otherwise it is derived as the contiguous slice of
507+
the original text that spans the result atoms' ``text_span`` extents.
508+
"""
509+
if result is original:
510+
return result
511+
if original.atom or result.atom:
512+
return result
513+
src_text = original.text
514+
if src_text is None:
515+
return result
516+
517+
orig_roots = sorted(a.root() for a in original.all_atoms())
518+
new_roots = sorted(a.root() for a in result.all_atoms())
519+
520+
if orig_roots == new_roots:
521+
new_text: str | None = src_text
522+
else:
523+
new_text = _derive_text_from_spans(result, src_text)
524+
if new_text is None:
525+
return result
526+
527+
object.__setattr__(result, "_text", new_text)
528+
if original.tokens is not None:
529+
object.__setattr__(result, "tokens", original.tokens)
530+
return result
531+
532+
533+
def _derive_text_from_spans(edge: Hyperedge, source_text: str) -> str | None:
534+
spans = [atom.text_span for atom in edge.all_atoms() if atom.text_span is not None]
535+
if not spans:
536+
return None
537+
start = min(s[0] for s in spans)
538+
end = max(s[1] for s in spans)
539+
return source_text[start:end]

tests/test_hyperedge.py

Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1337,6 +1337,135 @@ def test_transform_partial_match_unchanged(self):
13371337
)
13381338
assert result == edge
13391339

1340+
def test_transform_preserves_root_text_same_roots(self):
1341+
from hyperbase.parsers.parse_result import ParseResult
1342+
1343+
pr = ParseResult(
1344+
edge=hedge("(eats/Pd.so john/C apples/C)"),
1345+
text="John eats apples",
1346+
tokens=["John", "eats", "apples"],
1347+
tok_pos=hedge("(1 0 2)"),
1348+
)
1349+
loaded = hedge(pr)
1350+
# Decoration-only changes leave the atom roots untouched: text and
1351+
# source tokens flow through to the result root verbatim.
1352+
result = loaded.transform(
1353+
hedge("(eats/Pd.so X Y)"),
1354+
hedge("(eats/Pd.so X/Cp Y/Cp)"),
1355+
)
1356+
assert result.text == "John eats apples"
1357+
assert result.tokens == ("John", "eats", "apples")
1358+
1359+
def test_transform_derives_root_text_when_roots_change(self):
1360+
from hyperbase.parsers.parse_result import ParseResult
1361+
1362+
pr = ParseResult(
1363+
edge=hedge("(eats/Pd.so john/C apples/C)"),
1364+
text="John eats apples",
1365+
tokens=["John", "eats", "apples"],
1366+
tok_pos=hedge("(1 0 2)"),
1367+
)
1368+
loaded = hedge(pr)
1369+
# Drop the predicate via the {} preserve form: the resulting root has
1370+
# a different atom-root set, so text is derived from atom spans.
1371+
result = loaded.transform(
1372+
hedge("(eats/Pd.so X Y)"),
1373+
hedge("Y"),
1374+
)
1375+
# Y -> apples/C with text_span (10, 16) in "John eats apples".
1376+
assert str(result) == "apples/C"
1377+
assert result.text == "apples"
1378+
1379+
def test_replace_argroles_preserves_root_text(self):
1380+
from hyperbase.parsers.parse_result import ParseResult
1381+
1382+
pr = ParseResult(
1383+
edge=hedge("(is/P.sc john/C tired/C)"),
1384+
text="John is tired",
1385+
tokens=["John", "is", "tired"],
1386+
tok_pos=hedge("(1 0 2)"),
1387+
)
1388+
loaded = hedge(pr)
1389+
result = loaded.replace_argroles("os")
1390+
assert result.text == "John is tired"
1391+
assert result.tokens == ("John", "is", "tired")
1392+
1393+
def test_add_argument_preserves_root_text_when_arg_in_source(self):
1394+
from hyperbase.parsers.parse_result import ParseResult
1395+
1396+
pr = ParseResult(
1397+
edge=hedge("(eats/Pd.so john/C)"),
1398+
text="John eats apples",
1399+
tokens=["John", "eats", "apples"],
1400+
tok_pos=hedge("(1 0)"),
1401+
)
1402+
loaded = hedge(pr)
1403+
# Build the new arg from the parse so it carries source spans.
1404+
apples_pr = ParseResult(
1405+
edge=hedge("apples/C"),
1406+
text="John eats apples",
1407+
tokens=["John", "eats", "apples"],
1408+
tok_pos=hedge("2"),
1409+
)
1410+
apples = hedge(apples_pr)
1411+
result = loaded.add_argument(apples, "o")
1412+
# Roots changed (added apples), so text is derived. Slice spans
1413+
# from John to apples — the full source text.
1414+
assert result.text == "John eats apples"
1415+
1416+
def test_transform_constant_inherits_metadata_by_root(self):
1417+
from hyperbase.parsers.parse_result import ParseResult
1418+
1419+
pr = ParseResult(
1420+
edge=hedge("(yesterday/Mt died/Mn fido/Cc)"),
1421+
text="Yesterday died Fido",
1422+
tokens=["Yesterday", "died", "Fido"],
1423+
tok_pos=hedge("(0 1 2)"),
1424+
)
1425+
loaded = hedge(pr)
1426+
# died/Md (constant target) shares root with died/Mn in the original;
1427+
# tok_pos and text_span must be inherited even though the decoration
1428+
# changed.
1429+
result = loaded.transform(hedge("died/Mn"), hedge("died/Md"))
1430+
assert str(result) == "(yesterday/Mt died/Md fido/Cc)"
1431+
assert result[1].tok_pos == 1
1432+
assert result[1].text_span == (10, 14)
1433+
1434+
def test_replace_atom_inherits_metadata_when_same_root(self):
1435+
from hyperbase.parsers.parse_result import ParseResult
1436+
1437+
pr = ParseResult(
1438+
edge=hedge("(eats/Pd.so john/C apples/C)"),
1439+
text="John eats apples",
1440+
tokens=["John", "eats", "apples"],
1441+
tok_pos=hedge("(1 0 2)"),
1442+
)
1443+
loaded = hedge(pr)
1444+
old = loaded[2] # apples/C with tok_pos=2, text_span=(10, 16)
1445+
new = hedge("apples/Cp") # bare atom, no metadata
1446+
result = loaded.replace_atom(old, new)
1447+
# Metadata flows through because the root matches.
1448+
assert str(result[2]) == "apples/Cp"
1449+
assert result[2].tok_pos == 2
1450+
assert result[2].text_span == (10, 16)
1451+
1452+
def test_replace_atom_no_inherit_when_root_differs(self):
1453+
from hyperbase.parsers.parse_result import ParseResult
1454+
1455+
pr = ParseResult(
1456+
edge=hedge("(eats/Pd.so john/C apples/C)"),
1457+
text="John eats apples",
1458+
tokens=["John", "eats", "apples"],
1459+
tok_pos=hedge("(1 0 2)"),
1460+
)
1461+
loaded = hedge(pr)
1462+
old = loaded[2]
1463+
new = hedge("oranges/C")
1464+
result = loaded.replace_atom(old, new)
1465+
assert str(result[2]) == "oranges/C"
1466+
assert result[2].tok_pos is None
1467+
assert result[2].text_span is None
1468+
13401469

13411470
if __name__ == "__main__":
13421471
unittest.main()

0 commit comments

Comments
 (0)