Skip to content

Commit 3f83547

Browse files
committed
per-atom tok_pos/text_span and per-root Hyperedge.tokens source-position metadata; continuity-aware sub-edge text derivation with verbatim character-offset slicing
1 parent 97f32ee commit 3f83547

9 files changed

Lines changed: 485 additions & 59 deletions

File tree

CHANGELOG.md

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,16 +4,25 @@
44

55
### Added
66

7-
- Hyperedge.transform(): pattern-based rewrites.
7+
- `Hyperedge.transform()`: pattern-based rewrites.
8+
- per-atom `tok_pos`/`text_span` and per-root `Hyperedge.tokens` source-position metadata.
9+
- continuity-aware sub-edge text derivation with verbatim character-offset slicing.
10+
- `transforms.tok_pos_tree(edge)` rebuilds the parallel `tok_pos` tree from in-memory atoms.
811
- readers provide source information.
912
- `hyperbase.parsers.badness` module for parser-agnostic combined structural + token-matching validation.
1013
- built-in REPL setting `check_badness` to render a badness panel after each parse, available regardless of the active parser plugin.
1114
- more REPL commands: /load, /search, /count, /types and /transform.
1215

1316
### Changed
1417

18+
- transforms propagate per-atom `tok_pos`/`text_span` through rewrites.
19+
- REPL `/load` now uses `ParseResult.from_dict` (was dropping `tokens`/`tok_pos`).
1520
- REPL shows multiple results if available.
1621

22+
### Fixed
23+
24+
- pattern matcher no longer returns partial bindings on structural mismatch in `{...}` argrole patterns.
25+
1726
### Removed
1827

1928
## [0.10.0] - 11-04-2026

src/hyperbase/builders.py

Lines changed: 124 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
from __future__ import annotations
22

3-
from collections.abc import Iterable
3+
import itertools
4+
from collections.abc import Iterable, Iterator
45
from typing import Any, cast
56

67
from 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

159253
def 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)

src/hyperbase/cli/repl.py

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1115,6 +1115,8 @@ def _render_count_row(self, n: int, key: object, count: int) -> None:
11151115
self.console.print()
11161116

11171117
def _load_edges_from_jsonl(self, path: Path) -> tuple[list[Hyperedge], int]:
1118+
from hyperbase.parsers.parse_result import ParseResult
1119+
11181120
edges: list[Hyperedge] = []
11191121
skipped = 0
11201122
with open(path) as f:
@@ -1128,12 +1130,23 @@ def _load_edges_from_jsonl(self, path: Path) -> tuple[list[Hyperedge], int]:
11281130
if not isinstance(edge_str, str):
11291131
skipped += 1
11301132
continue
1131-
edge = hedge(edge_str)
1133+
# Prefer the full parse-result path so loaded edges carry
1134+
# tokens, text, and per-atom tok_pos/text_span metadata.
1135+
# Fall back to a bare hedge() parse if the row only has
1136+
# the edge string.
1137+
if (
1138+
"tokens" in d
1139+
and "tok_pos" in d
1140+
and isinstance(d.get("text"), str)
1141+
):
1142+
edge = hedge(ParseResult.from_dict(d))
1143+
else:
1144+
edge = hedge(edge_str)
11321145
if edge is None:
11331146
skipped += 1
11341147
continue
11351148
edges.append(edge)
1136-
except (json.JSONDecodeError, ValueError, TypeError):
1149+
except (json.JSONDecodeError, ValueError, TypeError, KeyError):
11371150
skipped += 1
11381151
return edges, skipped
11391152

src/hyperbase/hyperedge.py

Lines changed: 42 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -12,14 +12,21 @@ class Hyperedge:
1212
"""Non-atomic hyperedge."""
1313

1414
_edges: tuple[Hyperedge, ...]
15-
text: str | None
15+
_text: str | None
16+
tokens: tuple[str, ...] | None = field(default=None, compare=False, hash=False)
1617
_cache: dict[str, Any] = field(
1718
default_factory=dict, repr=False, compare=False, hash=False
1819
)
1920

20-
def __init__(self, edges: Iterable[Hyperedge], text: str | None = None) -> None:
21+
def __init__(
22+
self,
23+
edges: Iterable[Hyperedge],
24+
text: str | None = None,
25+
tokens: tuple[str, ...] | None = None,
26+
) -> None:
2127
object.__setattr__(self, "_edges", tuple(edges))
22-
object.__setattr__(self, "text", text)
28+
object.__setattr__(self, "_text", text)
29+
object.__setattr__(self, "tokens", tokens)
2330
object.__setattr__(self, "_cache", {})
2431

2532
def __iter__(self) -> Iterator[Hyperedge]:
@@ -49,6 +56,20 @@ def __eq__(self, other: object) -> bool:
4956
def __bool__(self) -> bool:
5057
return True
5158

59+
@property
60+
def text(self) -> str | None:
61+
"""Original-source text for this (sub-)edge.
62+
63+
Populated eagerly by the parse-result builder: an atom carries the
64+
substring it was located at (or ``None`` for synthetic atoms); a
65+
non-atom carries the continuity-aware derivation against its loaded
66+
root (one or more verbatim slices of ``root.text``, joined). Returns
67+
``None`` for edges built outside of a parse-result load (e.g. fresh
68+
transform output) -- those preserve per-atom ``tok_pos``/``text_span``
69+
but not the cached non-atom slice.
70+
"""
71+
return self._text
72+
5273
@property
5374
def atom(self) -> bool:
5475
"""True if edge is an atom."""
@@ -451,16 +472,23 @@ class Atom(Hyperedge):
451472

452473
atom_str: str
453474
parens: bool
475+
tok_pos: int | None = field(default=None, compare=False, hash=False)
476+
text_span: tuple[int, int] | None = field(default=None, compare=False, hash=False)
454477

455478
def __init__(
456479
self,
457480
atom_str: str,
458481
parens: bool = False,
459482
text: str | None = None,
483+
tok_pos: int | None = None,
484+
text_span: tuple[int, int] | None = None,
460485
) -> None:
461486
object.__setattr__(self, "atom_str", atom_str)
462487
object.__setattr__(self, "parens", parens)
463-
object.__setattr__(self, "text", text)
488+
object.__setattr__(self, "_text", text)
489+
object.__setattr__(self, "tok_pos", tok_pos)
490+
object.__setattr__(self, "text_span", text_span)
491+
object.__setattr__(self, "tokens", None)
464492
object.__setattr__(self, "_edges", ())
465493
object.__setattr__(self, "_cache", {})
466494

@@ -492,11 +520,19 @@ def root(self) -> str:
492520
return self.parts()[0]
493521

494522
def replace_atom_part(self, part_pos: int, part: str) -> Atom:
495-
"""Build a new atom by replacing an atom part in a given atom."""
523+
"""Build a new atom by replacing an atom part in a given atom.
524+
525+
Source-position metadata (``tok_pos`` / ``text_span``) is preserved.
526+
"""
496527
parts = self.parts()
497528
parts[part_pos] = part
498529
atom_str = "/".join([part for part in parts if part])
499-
return Atom(atom_str)
530+
return Atom(
531+
atom_str,
532+
text=self._text,
533+
tok_pos=self.tok_pos,
534+
text_span=self.text_span,
535+
)
500536

501537
def label(self) -> str:
502538
"""Generate human-readable label from entity."""

src/hyperbase/patterns/matcher.py

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -339,12 +339,12 @@ def _match_by_argroles(
339339
for pitem in pitems
340340
]
341341

342-
# early exit if any pattern position has zero candidates
342+
# early exit if any pattern position has zero candidates: the role is
343+
# present but no edge item can fill it, which is a hard mismatch -- never
344+
# a partial. (This differs from the len(eitems) < n case above, which
345+
# corresponds to a missing-but-optional role under the {req,opt} syntax.)
343346
if any(len(c) == 0 for c in candidates):
344-
if len(curvars) >= min_vars:
345-
return [curvars]
346-
else:
347-
return []
347+
return []
348348

349349
result: list[dict[str, Hyperedge]] = []
350350

0 commit comments

Comments
 (0)