htmlsift 0.1.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.
- htmlsift/__init__.py +9 -0
- htmlsift/_artifacts.py +64 -0
- htmlsift/cli.py +29 -0
- htmlsift/core/__init__.py +6 -0
- htmlsift/core/band.py +144 -0
- htmlsift/core/features.py +129 -0
- htmlsift/core/onnx_infer.py +38 -0
- htmlsift/core/prep_page.py +93 -0
- htmlsift/core/prune.py +42 -0
- htmlsift/core/render.py +328 -0
- htmlsift/core/tokenizer.py +38 -0
- htmlsift/core/torch_infer.py +165 -0
- htmlsift/extractor.py +91 -0
- htmlsift/loader.py +82 -0
- htmlsift/py.typed +0 -0
- htmlsift-0.1.0.dist-info/METADATA +89 -0
- htmlsift-0.1.0.dist-info/RECORD +20 -0
- htmlsift-0.1.0.dist-info/WHEEL +4 -0
- htmlsift-0.1.0.dist-info/entry_points.txt +2 -0
- htmlsift-0.1.0.dist-info/licenses/LICENSE +201 -0
htmlsift/__init__.py
ADDED
htmlsift/_artifacts.py
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"""Where the model bundles live and how the package fetches them.
|
|
2
|
+
|
|
3
|
+
Bundles are published to a public HuggingFace repo and pinned by commit SHA per
|
|
4
|
+
release, so a given htmlsift version always fetches byte-identical files. Fetching
|
|
5
|
+
uses hf_hub_download, which caches to the standard HF cache; a local directory can
|
|
6
|
+
override the fetch during development.
|
|
7
|
+
"""
|
|
8
|
+
import json
|
|
9
|
+
from dataclasses import dataclass, field
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
from huggingface_hub import hf_hub_download
|
|
13
|
+
|
|
14
|
+
REPO = "koivualeksi/htmlsift-release"
|
|
15
|
+
REVISION = "73ac2b978aef356e9e5772640a0c9444f2b67705" # release commit with both mini/
|
|
16
|
+
# and base/ bundles. Pin per release so a version fetches byte-
|
|
17
|
+
# identical files; None (dev only) would track the default branch.
|
|
18
|
+
|
|
19
|
+
# Bundle filenames. TABLE/HEAD are the mini ONNX graphs and must match what
|
|
20
|
+
# tools/release.py publishes (research-side).
|
|
21
|
+
TABLE = "mini-table-int8.onnx"
|
|
22
|
+
HEAD = "mini-head-fABC.onnx"
|
|
23
|
+
TOKENIZER = "tokenizer.json"
|
|
24
|
+
MANIFEST = "manifest.json"
|
|
25
|
+
|
|
26
|
+
# Each mode's bundle as {logical name -> filename}. Remote layout is <mode>/<filename>.
|
|
27
|
+
BUNDLE_FILES = {
|
|
28
|
+
"mini": {"table": TABLE, "head": HEAD, "tokenizer": TOKENIZER, "manifest": MANIFEST},
|
|
29
|
+
"base": {"config": "config.json", "weights": "weights.pt",
|
|
30
|
+
"tokenizer": TOKENIZER, "manifest": MANIFEST},
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass
|
|
35
|
+
class Manifest:
|
|
36
|
+
"""The per-model config that travels in the bundle (feats spec + z-score stats +
|
|
37
|
+
sizes), read from manifest.json so the package needs no torch to load it."""
|
|
38
|
+
mode: str
|
|
39
|
+
feats: str | None = None
|
|
40
|
+
hidden: int | None = None
|
|
41
|
+
window: int = 8192
|
|
42
|
+
cap: int = 0
|
|
43
|
+
zscore_mean: list = field(default_factory=list)
|
|
44
|
+
zscore_std: list = field(default_factory=list)
|
|
45
|
+
|
|
46
|
+
@classmethod
|
|
47
|
+
def from_file(cls, path):
|
|
48
|
+
d = json.loads(Path(path).read_text(encoding="utf-8"))
|
|
49
|
+
z = d.get("zscore") or {}
|
|
50
|
+
return cls(mode=d["mode"], feats=d.get("feats"), hidden=d.get("hidden"),
|
|
51
|
+
window=d.get("window", 8192), cap=d.get("cap", 0),
|
|
52
|
+
zscore_mean=z.get("mean", []), zscore_std=z.get("std", []))
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def resolve_bundle(mode, local_dir=None):
|
|
56
|
+
"""{logical name -> local file path} for `mode`'s bundle. A local_dir reads the
|
|
57
|
+
files straight off disk (development); otherwise each is pulled from the release
|
|
58
|
+
repo at the pinned revision and cached by huggingface_hub."""
|
|
59
|
+
files = BUNDLE_FILES[mode]
|
|
60
|
+
if local_dir:
|
|
61
|
+
base = Path(local_dir)
|
|
62
|
+
return {name: base / fn for name, fn in files.items()}
|
|
63
|
+
return {name: Path(hf_hub_download(REPO, f"{mode}/{fn}", revision=REVISION))
|
|
64
|
+
for name, fn in files.items()}
|
htmlsift/cli.py
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""`htmlsift` command line: prefetch model bundles into the Hugging Face cache.
|
|
2
|
+
|
|
3
|
+
htmlsift download mini # fetch + cache the mini bundle
|
|
4
|
+
htmlsift download base # fetch + cache the base bundle
|
|
5
|
+
"""
|
|
6
|
+
import argparse
|
|
7
|
+
|
|
8
|
+
from ._artifacts import BUNDLE_FILES, resolve_bundle
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _download(mode):
|
|
12
|
+
paths = resolve_bundle(mode) # hf_hub_download each file -> cached
|
|
13
|
+
print(f"{mode}: cached {len(paths)} files")
|
|
14
|
+
for name, p in paths.items():
|
|
15
|
+
print(f" {name:10} {p}")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def main(argv=None):
|
|
19
|
+
ap = argparse.ArgumentParser(prog="htmlsift")
|
|
20
|
+
sub = ap.add_subparsers(dest="cmd", required=True)
|
|
21
|
+
dl = sub.add_parser("download", help="prefetch a model bundle into the HF cache")
|
|
22
|
+
dl.add_argument("mode", choices=list(BUNDLE_FILES))
|
|
23
|
+
args = ap.parse_args(argv)
|
|
24
|
+
if args.cmd == "download":
|
|
25
|
+
_download(args.mode)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
if __name__ == "__main__":
|
|
29
|
+
main()
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
"""Vendored inference engine, relicensed Apache-2.0 from htmlsift-research/core.
|
|
2
|
+
|
|
3
|
+
Kept as a faithful copy so each module diffs cleanly against its research original; the
|
|
4
|
+
only intended change is render.py's output-only ``links=`` extension. See packaging_plan.md
|
|
5
|
+
sections 4-5.
|
|
6
|
+
"""
|
htmlsift/core/band.py
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Chunked band attention for the encoder's sliding-attention layers -- a speed
|
|
3
|
+
optimization that changes nothing about the output.
|
|
4
|
+
|
|
5
|
+
transformers builds the sliding window as a dense (B, 1, L, L) boolean mask and
|
|
6
|
+
hands it to scaled_dot_product_attention, whose CPU kernel scores the whole
|
|
7
|
+
L x L matrix and then discards the out-of-band entries -- so a sliding layer
|
|
8
|
+
costs as much as a full one and local_attention buys nothing. This computes the
|
|
9
|
+
same numbers in O(L*w) by cutting the sequence into chunks and letting each
|
|
10
|
+
query chunk attend only to the key slice it can reach.
|
|
11
|
+
|
|
12
|
+
The arithmetic is unchanged: the per-chunk predicate is exactly
|
|
13
|
+
abs(q_idx - kv_idx) <= config.sliding_window, the same one transformers' sliding
|
|
14
|
+
mask applies. So this is a speed-only optimization gated on numeric identity, not on
|
|
15
|
+
a val score -- it computes the same numbers as stock sliding attention, only faster.
|
|
16
|
+
|
|
17
|
+
import core.band
|
|
18
|
+
core.band.enable(encoder) # in place, idempotent
|
|
19
|
+
"""
|
|
20
|
+
import torch
|
|
21
|
+
import torch.nn.functional as F
|
|
22
|
+
from transformers.integrations.sdpa_attention import sdpa_attention_forward
|
|
23
|
+
from transformers.masking_utils import ALL_MASK_ATTENTION_FUNCTIONS, sdpa_mask
|
|
24
|
+
from transformers.modeling_utils import ALL_ATTENTION_FUNCTIONS
|
|
25
|
+
|
|
26
|
+
IMPL = "sdpa_band"
|
|
27
|
+
CHUNK = 128
|
|
28
|
+
# Below this the reshaping costs more than the scores it saves, and the dense
|
|
29
|
+
# band mask is only a few hundred KB -- exactly what transformers would build.
|
|
30
|
+
MIN_CHUNKED = 4 * CHUNK
|
|
31
|
+
_NO_MASK = {"full_attention": None, "sliding_attention": None}
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _dense_band_mask(n_tok, half, device):
|
|
35
|
+
"""(1, 1, L, L) bool -- the sliding mask transformers would have built."""
|
|
36
|
+
i = torch.arange(n_tok, device=device)
|
|
37
|
+
return ((i[None, :] - i[:, None]).abs() <= half)[None, None]
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _chunk_mask(chunk, half, n_chunks, n_tok, device):
|
|
41
|
+
"""(n_chunks, 1, chunk, chunk + 2*half) bool: inside the band AND a real
|
|
42
|
+
token. A query row past the sequence end has no valid key and softmax over an
|
|
43
|
+
all-False row is NaN, so each dead row gets its own zero slot; those rows are
|
|
44
|
+
sliced off before the result returns."""
|
|
45
|
+
span = chunk + 2 * half
|
|
46
|
+
j = torch.arange(chunk, device=device)
|
|
47
|
+
t = torch.arange(span, device=device)
|
|
48
|
+
band = (t[None, :] - half - j[:, None]).abs() <= half
|
|
49
|
+
c = torch.arange(n_chunks, device=device)
|
|
50
|
+
kpos = c[:, None] * chunk - half + t[None, :]
|
|
51
|
+
m = band[None] & ((kpos >= 0) & (kpos < n_tok))[:, None, :]
|
|
52
|
+
dead = (c[:, None] * chunk + j[None, :]) >= n_tok
|
|
53
|
+
if bool(dead.any()):
|
|
54
|
+
ci, ji = torch.nonzero(dead, as_tuple=True)
|
|
55
|
+
m[ci, ji, half + ji] = True
|
|
56
|
+
return m[:, None]
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def band_sdpa_forward(module, query, key, value, attention_mask, dropout=0.0,
|
|
60
|
+
scaling=None, sliding_window=None, **kwargs):
|
|
61
|
+
"""Drop-in for sdpa_attention_forward. The band path runs only for a sliding
|
|
62
|
+
layer with nothing to pad; a full layer (sliding_window is None) or any real
|
|
63
|
+
padding mask is delegated to stock sdpa, unchanged."""
|
|
64
|
+
if sliding_window is None or attention_mask is not None:
|
|
65
|
+
return sdpa_attention_forward(module, query, key, value, attention_mask,
|
|
66
|
+
dropout=dropout, scaling=scaling,
|
|
67
|
+
sliding_window=sliding_window, **kwargs)
|
|
68
|
+
|
|
69
|
+
half = int(module.config.sliding_window)
|
|
70
|
+
B, H, L, D = query.shape
|
|
71
|
+
|
|
72
|
+
if L <= MIN_CHUNKED:
|
|
73
|
+
out = F.scaled_dot_product_attention(
|
|
74
|
+
query, key, value, attn_mask=_dense_band_mask(L, half, query.device),
|
|
75
|
+
dropout_p=dropout, scale=scaling, is_causal=False)
|
|
76
|
+
return out.transpose(1, 2).contiguous(), None
|
|
77
|
+
|
|
78
|
+
chunk = max(CHUNK, half)
|
|
79
|
+
span = chunk + 2 * half
|
|
80
|
+
pad = (-L) % chunk
|
|
81
|
+
n = (L + pad) // chunk
|
|
82
|
+
|
|
83
|
+
q = F.pad(query, (0, 0, 0, pad)).view(B, H, n, chunk, D)
|
|
84
|
+
q = q.permute(0, 2, 1, 3, 4).reshape(B * n, H, chunk, D)
|
|
85
|
+
kv = []
|
|
86
|
+
for x in (key, value):
|
|
87
|
+
x = F.pad(x, (0, 0, half, pad + half)).unfold(2, span, chunk)
|
|
88
|
+
kv.append(x.permute(0, 2, 1, 4, 3).reshape(B * n, H, span, D))
|
|
89
|
+
|
|
90
|
+
m = _chunk_mask(chunk, half, n, L, query.device)
|
|
91
|
+
if B > 1:
|
|
92
|
+
m = m.repeat(B, 1, 1, 1)
|
|
93
|
+
|
|
94
|
+
out = F.scaled_dot_product_attention(q, kv[0], kv[1], attn_mask=m,
|
|
95
|
+
dropout_p=dropout, scale=scaling,
|
|
96
|
+
is_causal=False)
|
|
97
|
+
out = out.view(B, n, H, chunk, D).permute(0, 2, 1, 3, 4)
|
|
98
|
+
out = out.reshape(B, H, n * chunk, D)[:, :, :L]
|
|
99
|
+
return out.transpose(1, 2).contiguous(), None
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def register():
|
|
103
|
+
ALL_ATTENTION_FUNCTIONS.register(IMPL, band_sdpa_forward)
|
|
104
|
+
# Only reached on the fallback path (a real padding mask), but a missing key
|
|
105
|
+
# here would be a KeyError deep inside mask construction.
|
|
106
|
+
ALL_MASK_ATTENTION_FUNCTIONS.register(IMPL, sdpa_mask)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _no_mask_hook(module, args, kwargs):
|
|
110
|
+
"""Replace an all-ones 2D mask with the "no mask" mapping so the model never
|
|
111
|
+
materialises the dense (1, 1, L, L) sliding mask -- the very thing the band
|
|
112
|
+
path exists to avoid. ModernBertModel.forward uses a mask dict verbatim when
|
|
113
|
+
given one, so {'full_attention': None, 'sliding_attention': None} makes every
|
|
114
|
+
layer receive None and band_sdpa_forward take the fast path. Anything that is
|
|
115
|
+
NOT an all-ones 2D mask (real padding) is left alone: the dense mask is built
|
|
116
|
+
and band_sdpa_forward falls back to stock sdpa. The fast path can therefore
|
|
117
|
+
only run when there is nothing to pad.
|
|
118
|
+
|
|
119
|
+
A forward_pre_hook, NOT a wrapped encoder.forward, and that is load-bearing.
|
|
120
|
+
torch.ao.quantization.quantize_dynamic returns a deep copy; a closure stored
|
|
121
|
+
as encoder.forward keeps pointing at the original fp32 module, so enable()
|
|
122
|
+
then quantize would silently run fp32 while reporting every layer quantized.
|
|
123
|
+
Hooks live in _forward_pre_hooks, are copied with the module, and take module
|
|
124
|
+
as an argument instead of capturing it.
|
|
125
|
+
"""
|
|
126
|
+
am = kwargs.get("attention_mask")
|
|
127
|
+
if am is None or (torch.is_tensor(am) and am.dim() == 2 and bool(am.all())):
|
|
128
|
+
kwargs["attention_mask"] = _NO_MASK
|
|
129
|
+
return args, kwargs
|
|
130
|
+
return None
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def enable(encoder):
|
|
134
|
+
"""Switch a ModernBERT encoder to band attention, in place. Idempotent."""
|
|
135
|
+
register()
|
|
136
|
+
encoder.config._attn_implementation = IMPL
|
|
137
|
+
for m in encoder.modules(): # layers may hold their own config copy
|
|
138
|
+
cfg = getattr(m, "config", None)
|
|
139
|
+
if cfg is not None:
|
|
140
|
+
cfg._attn_implementation = IMPL
|
|
141
|
+
if not getattr(encoder, "_band_enabled", False):
|
|
142
|
+
encoder.register_forward_pre_hook(_no_mask_hook, with_kwargs=True)
|
|
143
|
+
encoder._band_enabled = True
|
|
144
|
+
return encoder
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
"""Structural block features for the table-embedding arm (mini): the signal a
|
|
2
|
+
mean-pooled bag of token embeddings cannot carry, and nothing the rendered text
|
|
3
|
+
already shows.
|
|
4
|
+
|
|
5
|
+
Computed from render_tree's line_src (the DOM elements behind each block) -- no
|
|
6
|
+
second parse, no marker injection, no change to the renderer. Booleans for
|
|
7
|
+
presence; only the two genuine scalars (depth, log_chars) are z-scored, using the
|
|
8
|
+
stats shipped in the model bundle.
|
|
9
|
+
|
|
10
|
+
A column earns its place under one rule: it is absent from the tokenized block text
|
|
11
|
+
AND not recoverable by the BiGRU. Tag structure is flattened by markdown, and any
|
|
12
|
+
marker that survives is diluted by the per-block mean-pool; DOM depth and
|
|
13
|
+
link-ancestry never appear in the rendered text at all (the renderer prints an <a>
|
|
14
|
+
as bare text); block length is destroyed the moment the tokens are averaged.
|
|
15
|
+
|
|
16
|
+
This module is the feature manifest: column order and the group map live here and
|
|
17
|
+
nowhere else.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
import math
|
|
21
|
+
|
|
22
|
+
import numpy as np
|
|
23
|
+
|
|
24
|
+
# Group A -- ancestor-or-self tag presence, 30 booleans. Semantic containers that
|
|
25
|
+
# markdown flattens away, plus block tags whose surviving text marker the mean-pool
|
|
26
|
+
# dilutes.
|
|
27
|
+
TAG_VOCAB = [
|
|
28
|
+
"main", "article", "section", "nav", "header", "footer", "aside",
|
|
29
|
+
"h1", "h2", "h3", "h4", "h5", "h6", "p", "li", "ul", "ol", "dl",
|
|
30
|
+
"table", "tr", "td", "th", "blockquote", "pre", "code",
|
|
31
|
+
"figure", "figcaption", "form", "button", "div",
|
|
32
|
+
]
|
|
33
|
+
|
|
34
|
+
GROUPS = {
|
|
35
|
+
"A": [f"tag_{t}" for t in TAG_VOCAB], # ancestor/self tag presence (boolean)
|
|
36
|
+
"B": ["depth", "has_link"], # DOM nesting depth (scalar); an <a> ancestor (boolean)
|
|
37
|
+
"C": ["log_chars"], # block length -- what the mean-pool averages away
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
FEATURE_NAMES = [name for g in ("A", "B", "C") for name in GROUPS[g]]
|
|
41
|
+
|
|
42
|
+
# The only unbounded columns; z-scored with the bundle's stats. Booleans and
|
|
43
|
+
# already-bounded columns stay raw.
|
|
44
|
+
ZSCORE_COLS = ["depth", "log_chars"]
|
|
45
|
+
|
|
46
|
+
_ORDER = ("A", "B", "C")
|
|
47
|
+
_VOCAB = set(TAG_VOCAB)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def collect_features(lines, line_src, groups=_ORDER):
|
|
51
|
+
"""(lines, line_src) from render_tree -> [n_blocks, K] float32, one row per
|
|
52
|
+
non-empty line (the block unit). Columns are the requested groups in manifest
|
|
53
|
+
order; only those groups are computed, so a text-only arm passes groups=() and
|
|
54
|
+
pays nothing. The ancestor chain of each element is cached and shared across
|
|
55
|
+
blocks (below)."""
|
|
56
|
+
groups = [g for g in _ORDER if g in groups]
|
|
57
|
+
cols = [c for g in groups for c in GROUPS[g]]
|
|
58
|
+
col = {c: i for i, c in enumerate(cols)}
|
|
59
|
+
blocks = [(ln, src) for ln, src in zip(lines, line_src, strict=True) if ln.strip()]
|
|
60
|
+
out = np.zeros((len(blocks), len(cols)), dtype=np.float32)
|
|
61
|
+
need_a, need_b, need_c = "A" in groups, "B" in groups, "C" in groups
|
|
62
|
+
|
|
63
|
+
# element -> (vocab tags, depth, link) for its ancestor-or-self chain, computed
|
|
64
|
+
# once and reused. Every block re-reaches the same container chain to root, so
|
|
65
|
+
# caching collapses that repetition: the walk stops at the first cached ancestor.
|
|
66
|
+
# Keyed by the element object -- the cache holds the reference, which keeps the
|
|
67
|
+
# lxml proxy (and thus its identity) stable for the call.
|
|
68
|
+
chain = {}
|
|
69
|
+
|
|
70
|
+
def chain_info(e):
|
|
71
|
+
stack = []
|
|
72
|
+
node = e
|
|
73
|
+
while node is not None and isinstance(node.tag, str) and node not in chain:
|
|
74
|
+
stack.append(node)
|
|
75
|
+
node = node.getparent()
|
|
76
|
+
if node is not None and isinstance(node.tag, str):
|
|
77
|
+
tags, depth, link = chain[node]
|
|
78
|
+
else:
|
|
79
|
+
tags, depth, link = frozenset(), 0, False
|
|
80
|
+
for nd in reversed(stack): # fold from cached base to e
|
|
81
|
+
t = nd.tag
|
|
82
|
+
if need_a and t in _VOCAB and t not in tags:
|
|
83
|
+
tags = tags | {t}
|
|
84
|
+
depth += 1
|
|
85
|
+
link = link or t == "a"
|
|
86
|
+
chain[nd] = (tags, depth, link)
|
|
87
|
+
return tags, depth, link
|
|
88
|
+
|
|
89
|
+
for bi, (text, src) in enumerate(blocks):
|
|
90
|
+
if need_a or need_b:
|
|
91
|
+
btags, depth_sum, n_src, has_link = set(), 0, 0, False
|
|
92
|
+
for e in src:
|
|
93
|
+
et, ed, el = chain_info(e)
|
|
94
|
+
btags |= et
|
|
95
|
+
depth_sum += ed
|
|
96
|
+
n_src += 1
|
|
97
|
+
has_link = has_link or el
|
|
98
|
+
if need_a:
|
|
99
|
+
for t in btags:
|
|
100
|
+
out[bi, col[f"tag_{t}"]] = 1.0
|
|
101
|
+
if need_b:
|
|
102
|
+
out[bi, col["depth"]] = depth_sum / n_src if n_src else 0.0
|
|
103
|
+
out[bi, col["has_link"]] = float(has_link)
|
|
104
|
+
if need_c:
|
|
105
|
+
out[bi, col["log_chars"]] = math.log1p(len(text))
|
|
106
|
+
return out
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def feature_dim(groups):
|
|
110
|
+
"""Number of feature columns a group selection produces (None/"" -> 0). Sizes
|
|
111
|
+
the head's d_in without hardcoding K."""
|
|
112
|
+
return sum(len(GROUPS[g]) for g in _ORDER if groups and g in groups)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _zscore_idx(groups):
|
|
116
|
+
"""Column positions of the unbounded scalars (ZSCORE_COLS) within a group
|
|
117
|
+
selection's layout -- lets the loader pair the bundle's mean/std with the right
|
|
118
|
+
columns for a given feats group."""
|
|
119
|
+
cols = [c for g in _ORDER if g in groups for c in GROUPS[g]]
|
|
120
|
+
return [i for i, c in enumerate(cols) if c in ZSCORE_COLS]
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def apply_zscore(fa, stats):
|
|
124
|
+
"""A copy of a page's [n_blocks, K] array with the z-scored columns normalized by
|
|
125
|
+
the bundle's (idx, mean, std) stats; booleans and bounded columns untouched."""
|
|
126
|
+
idx, mean, std = stats
|
|
127
|
+
out = fa.copy()
|
|
128
|
+
out[:, idx] = (out[:, idx] - mean) / std
|
|
129
|
+
return out
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""Torch-free onnxruntime forward for the mini model (int8 table + BiGRU head).
|
|
2
|
+
|
|
3
|
+
Embed the token ids through the int8 table graph, mean-pool by block membership,
|
|
4
|
+
concat any structural features, run the head graph, sigmoid. numpy + onnxruntime only
|
|
5
|
+
-- no torch. Lifted unchanged from the research export so it stays bit-identical to the
|
|
6
|
+
graphs' parity-gated reference forward.
|
|
7
|
+
"""
|
|
8
|
+
import numpy as np
|
|
9
|
+
import onnxruntime as ort
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def pool(emb, members):
|
|
13
|
+
"""Token embeddings emb [T, H] -> per-block mean vectors [n, H]. A token-less block
|
|
14
|
+
pools to zeros (sum of nothing / 1)."""
|
|
15
|
+
pooled = np.zeros((len(members), emb.shape[1]), dtype=np.float32)
|
|
16
|
+
for i, mem in enumerate(members):
|
|
17
|
+
if mem:
|
|
18
|
+
pooled[i] = emb[mem].mean(0)
|
|
19
|
+
return pooled
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def load_mini(table_path, head_path, threads=None):
|
|
23
|
+
"""The two mini graphs -> infer_fn(page) -> per-block probs (np.float32): the
|
|
24
|
+
(page -> probs) contract the extractor consumes."""
|
|
25
|
+
opts = ort.SessionOptions()
|
|
26
|
+
if threads:
|
|
27
|
+
opts.intra_op_num_threads, opts.inter_op_num_threads = threads, 1
|
|
28
|
+
table = ort.InferenceSession(str(table_path), opts, providers=["CPUExecutionProvider"])
|
|
29
|
+
head = ort.InferenceSession(str(head_path), opts, providers=["CPUExecutionProvider"])
|
|
30
|
+
|
|
31
|
+
def infer_fn(page):
|
|
32
|
+
emb = table.run(["out"], {"ids": page["ids"].astype(np.int64)})[0] # [T, H]
|
|
33
|
+
x = pool(emb, page["members"]) # [n, H]
|
|
34
|
+
if page.get("feats") is not None:
|
|
35
|
+
x = np.concatenate([x, page["feats"].astype(np.float32)], 1) # [n, H+K]
|
|
36
|
+
logits = head.run(["logits"], {"x": x[None]})[0][0] # [n]
|
|
37
|
+
return (1.0 / (1.0 + np.exp(-logits))).astype(np.float32)
|
|
38
|
+
return infer_fn
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
"""Page layout for inference: tokenize a page's blocks once, map every token back
|
|
2
|
+
to the block it came from, and (for the windowed encoder path) plan the token
|
|
3
|
+
windows. No gradients, no encoder -- deterministic shaping the forwards consume.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import numpy as np
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def block_spans(blocks):
|
|
10
|
+
"""Char span [start, end) of each block within "\\n".join(blocks)."""
|
|
11
|
+
spans, pos = [], 0
|
|
12
|
+
for b in blocks:
|
|
13
|
+
spans.append((pos, pos + len(b)))
|
|
14
|
+
pos += len(b) + 1 # +1 for the joining newline
|
|
15
|
+
return spans
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def page_members(enc, blocks):
|
|
19
|
+
"""Per block, the indices of the tokens whose characters fall inside it.
|
|
20
|
+
|
|
21
|
+
`enc` is a fast-tokenizer output carrying offset_mapping and
|
|
22
|
+
special_tokens_mask. A single cursor advances through the non-special tokens as
|
|
23
|
+
blocks are consumed left to right, so the whole map is one linear pass. A token
|
|
24
|
+
joins a block when it overlaps the block's characters; the `end > bs` guard
|
|
25
|
+
drops zero-width tokens (e.g. (0,0)) while keeping a subword that straddles the
|
|
26
|
+
joining newline with the block it reaches into. A block with no tokens of its
|
|
27
|
+
own gets [] and pools to a zero vector, so members stays aligned with blocks.
|
|
28
|
+
"""
|
|
29
|
+
offsets = enc["offset_mapping"]
|
|
30
|
+
special = enc["special_tokens_mask"]
|
|
31
|
+
tok_idx = [i for i in range(len(offsets)) if not special[i]]
|
|
32
|
+
members = []
|
|
33
|
+
ti = 0
|
|
34
|
+
for bs, be in block_spans(blocks):
|
|
35
|
+
while ti < len(tok_idx) and offsets[tok_idx[ti]][1] <= bs:
|
|
36
|
+
ti += 1
|
|
37
|
+
j, mem = ti, []
|
|
38
|
+
while j < len(tok_idx) and offsets[tok_idx[j]][0] < be:
|
|
39
|
+
if offsets[tok_idx[j]][1] > bs:
|
|
40
|
+
mem.append(tok_idx[j])
|
|
41
|
+
j += 1
|
|
42
|
+
members.append(mem)
|
|
43
|
+
return members
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def window_starts(n, window, stride=None):
|
|
47
|
+
"""Start offsets of the windows tiling n tokens (n > window). stride defaults to
|
|
48
|
+
window // 2 (50% overlap); the last window snaps back to n - window so the tail
|
|
49
|
+
is always fully covered. Shared with the encoder stitch (torch_infer.pool_page),
|
|
50
|
+
so window boundaries are defined in one place."""
|
|
51
|
+
stride = stride or window // 2
|
|
52
|
+
starts = list(range(0, n - window + 1, stride))
|
|
53
|
+
if not starts:
|
|
54
|
+
starts = [0]
|
|
55
|
+
if starts[-1] != n - window:
|
|
56
|
+
starts.append(n - window)
|
|
57
|
+
return starts
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def apply_cap(ids, members, k):
|
|
61
|
+
"""Keep each block's first k member tokens, dropping the rest from the sequence
|
|
62
|
+
-- a per-block width cap. Tokens belonging to no block (specials, the newline
|
|
63
|
+
joins) are always kept. Members are renumbered onto the compacted ids."""
|
|
64
|
+
keep = np.ones(len(ids), dtype=bool)
|
|
65
|
+
for mem in members:
|
|
66
|
+
if len(mem) > k:
|
|
67
|
+
keep[np.asarray(mem[k:], dtype=np.int64)] = False
|
|
68
|
+
newpos = np.cumsum(keep) - 1
|
|
69
|
+
return ids[keep], [[int(newpos[t]) for t in mem[:k]] for mem in members]
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def prep_page(tok, blocks, window, stride=None, cap=0, feats=None, tid=None):
|
|
73
|
+
"""Blocks -> the page dict the forwards consume: token ids, per-block members,
|
|
74
|
+
optional structural features, and the window plan. The page carries its own
|
|
75
|
+
window/stride so the encoder stitch cannot drift. truncation=False -- the page is
|
|
76
|
+
windowed by the encoder, never truncated by the tokenizer. cap>0 applies the
|
|
77
|
+
per-block width cap first.
|
|
78
|
+
|
|
79
|
+
feats, when given, is an [n_blocks, K] array (already z-scored on the training
|
|
80
|
+
stats, aligned with blocks) carried through to the head concat; feats=None runs
|
|
81
|
+
text-only. The width cap drops tokens, not blocks, so feats stays aligned."""
|
|
82
|
+
enc = tok("\n".join(blocks), add_special_tokens=True,
|
|
83
|
+
return_offsets_mapping=True, return_special_tokens_mask=True,
|
|
84
|
+
return_tensors=None, truncation=False)
|
|
85
|
+
ids = np.array(enc["input_ids"], dtype=np.int64)
|
|
86
|
+
members = page_members(enc, blocks)
|
|
87
|
+
if cap:
|
|
88
|
+
ids, members = apply_cap(ids, members, cap)
|
|
89
|
+
if feats is not None:
|
|
90
|
+
feats = np.asarray(feats, dtype=np.float32)
|
|
91
|
+
assert feats.shape[0] == len(members), (feats.shape[0], len(members))
|
|
92
|
+
return {"tid": tid, "ids": ids, "members": members, "feats": feats,
|
|
93
|
+
"window": window, "stride": stride}
|
htmlsift/core/prune.py
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""Prune a parsed DOM to the elements behind the selected blocks, then serialize.
|
|
2
|
+
|
|
3
|
+
The renderer records, per rendered line, the DOM elements that produced it (line_src).
|
|
4
|
+
Given the elements behind the KEPT lines, keep exactly those, their ancestors (the
|
|
5
|
+
structural path to root) and their descendants (inline links/images inside a kept block),
|
|
6
|
+
and drop everything else. lxml only -- no html2text.
|
|
7
|
+
"""
|
|
8
|
+
from lxml import html as lxml_html
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def keep_set(elements):
|
|
12
|
+
"""The nodes to keep: each element, its ancestors, and its descendants. Holding the
|
|
13
|
+
proxies in the set keeps their lxml identity stable for the prune walk."""
|
|
14
|
+
keep = set()
|
|
15
|
+
for el in elements:
|
|
16
|
+
if el in keep:
|
|
17
|
+
continue
|
|
18
|
+
keep.add(el)
|
|
19
|
+
keep.update(el.iterancestors())
|
|
20
|
+
keep.update(el.iterdescendants())
|
|
21
|
+
return keep
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def prune(root, elements):
|
|
25
|
+
"""Remove from root every element not in keep_set(elements); returns root (mutated)."""
|
|
26
|
+
keep = keep_set(elements)
|
|
27
|
+
|
|
28
|
+
def walk(el):
|
|
29
|
+
for child in list(el):
|
|
30
|
+
if child in keep:
|
|
31
|
+
walk(child)
|
|
32
|
+
else:
|
|
33
|
+
el.remove(child)
|
|
34
|
+
|
|
35
|
+
walk(root)
|
|
36
|
+
return root
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def to_html(root, elements):
|
|
40
|
+
"""Prune root to the selected elements and serialize to an HTML string."""
|
|
41
|
+
prune(root, elements)
|
|
42
|
+
return lxml_html.tostring(root, encoding="unicode")
|