nullspan / modeling_nullspan.py
gnumanth's picture
Release v1.1.0: 512-token sliding window + multi-numeric contract disambiguation
9c429e6 verified
Raw History Blame Contribute Delete
42.5 kB
"""Nullspan System One Extraction Model (Hugging Face PreTrainedModel).
Processes `state` + multiple typed `fields` in a single parallel encoder forward pass
with 5 task heads and 10 System One operations:
1. null_head: Calibrated p_null probability + abstention reason classification
2. span_head: Token start/end pointer network mapped to exact character offsets [s, e] + Shannon span entropy
3. bio_head: Token-level B/I/O multi-span sequence tagger for `type: "list"`
4. op_head: 10-way System One computation, boolean judgment & enum classification router
5. quantile_head: Pinball-loss calibrated distribution head (p10, median, p90) with monotonic sorting
"""
from __future__ import annotations
import datetime as dt
import math
import re
from typing import Any
import torch
import torch.nn as nn
import torch.nn.functional as F
from transformers import AutoConfig, AutoModel, PretrainedConfig, PreTrainedModel
class NullspanConfig(PretrainedConfig):
model_type = "nullspan"
def __init__(
self,
backbone_name: str = "distilbert/distilbert-base-uncased",
hidden_size: int = 768,
num_ops: int = 10,
num_bio_labels: int = 3,
dropout: float = 0.1,
null_temperature: float = 1.0,
span_temperature: float = 1.0,
max_length: int = 512,
**kwargs: Any,
) -> None:
super().__init__(**kwargs)
self.backbone_name = backbone_name
self.hidden_size = hidden_size
self.num_ops = num_ops
self.num_bio_labels = num_bio_labels
self.dropout = dropout
self.null_temperature = null_temperature
self.span_temperature = span_temperature
self.max_length = max_length
def format_field_query(field_name: str, spec: dict[str, Any]) -> str:
ftype = spec.get("type", "text")
unit = spec.get("unit", "")
items = spec.get("items", "")
options = spec.get("options", [])
constraint = spec.get("constraint", "")
instr = spec.get("instructions") or spec.get("instruction") or ""
parts = [f"field: {field_name}", f"type: {ftype}"]
if unit:
parts.append(f"unit: {unit}")
if items:
parts.append(f"items: {items}")
if options:
parts.append(f"options: {','.join(str(o) for o in options)}")
if constraint:
parts.append(f"constraint: {constraint}")
if instr:
parts.append(f"instructions: {instr}")
return " | ".join(parts)
_QA_STOPWORDS = {
"what", "who", "when", "where", "which", "whom", "whose", "why", "how",
"was", "were", "did", "does", "do", "is", "are", "been", "being", "be",
"the", "a", "an", "in", "on", "of", "to", "for", "with", "from", "by",
"at", "into", "unto", "upon", "about", "as", "that", "this", "these",
"those", "their", "his", "her", "its", "they", "them", "it", "he", "she",
"had", "has", "have", "would", "could", "should", "can", "may", "might",
}
_NUM_STOPWORDS = {
"what", "who", "when", "where", "which", "how", "much", "many",
"is", "are", "was", "were", "be", "the", "a", "an", "in", "on", "of",
"to", "for", "with", "from", "by", "at", "under", "this", "that",
"number", "numeric", "value", "amount", "total", "if", "present",
"applicable", "specified", "stated", "contract", "agreement", "document",
"usd", "dollar", "dollars", "percent", "percentage", "pct",
"day", "days", "month", "months", "year", "years", "hour", "hours",
}
_GENERIC_MEASURE_WORDS = {
"rate", "rates", "period", "periods", "fee", "fees", "limit", "limits",
"level", "levels", "time", "times", "window", "windows", "schedule",
"schedules", "ratio", "ratios", "charge", "charges", "cost", "costs",
"price", "prices", "payment", "payments", "duration",
}
def disambiguate_numeric_field(state: str, fname: str, spec: dict[str, Any]) -> dict[str, Any] | None:
"""Context-aware multi-numeric candidate ranker for contracts and documents with multiple competing numbers."""
fn_clean = fname.replace("_", " ")
instr_s = str(spec.get("instructions") or spec.get("instruction") or "")
q_raw = f"{fn_clean} {instr_s}".lower()
q_words: list[str] = []
for w in re.findall(r"[a-z]{2,}", q_raw):
if w not in _NUM_STOPWORDS and w not in q_words:
q_words.append(w)
specific_q_words = [w for w in q_words if w not in _GENERIC_MEASURE_WORDS]
u_val = str(spec.get("unit", "")).lower()
unit_str = f"{q_raw} {u_val}"
wants_pct = bool(re.search(r"\b(?:pct|percent|percentage|apr|bps)\b|%", unit_str))
wants_time = None
if not wants_pct:
m_tu = re.search(r"\b(day|month|year|hour|minute|week)s?\b", unit_str)
if m_tu and not re.search(r"\b(?:usd|dollars?|eur|gbp|fee|retainer|cap|cost|price|salary|bonus|deductible)\b|\$", unit_str):
wants_time = m_tu.group(1)
wants_currency = (not wants_pct and not wants_time) and bool(
re.search(
r"\b(?:usd|dollars?|eur|gbp|fee|retainer|cap|cost|price|credit|surcharge|payment|bonus|deductible|penalty|salary|budget|deposit|subtotal|revenue)\b|\$",
unit_str,
)
)
clauses = list(re.finditer(r"[^.!?\n]+[.!?\n]?", state))
def get_clause_bounds(pos: int) -> tuple[int, int]:
for cm in clauses:
if cm.start() <= pos < cm.end():
return cm.start(), cm.end()
return max(0, pos - 100), min(len(state), pos + 100)
candidates: list[tuple[float, float, int, float, list[int], str]] = []
for m in re.finditer(r"(?<![A-Za-z0-9_-])([$鈧B)?(\d+(?:,\d{3})*(?:\.\d+)?)(%)?(?![A-Za-z0-9_]|-\d)", state):
sym, num_s, pct_s = m.group(1), m.group(2), m.group(3)
s_idx, e_idx = m.start(), m.end()
val = float(num_s.replace(",", ""))
before_25 = state[max(0, s_idx - 25) : s_idx]
after_25 = state[e_idx : min(len(state), e_idx + 25)]
is_sec_hdr = bool(
re.search(r"(?:section|clause|article|page|part|schedule|exhibit)\s*$", before_25, re.I)
or (re.search(r"(?:^|\n)\s*$", before_25) and after_25.startswith(". "))
)
if is_sec_hdr and not any(w in q_raw for w in ("section", "clause", "page", "article")):
continue
c_start, c_end = get_clause_bounds(s_idx)
clause_txt = state[c_start:c_end].lower()
lex_score = 0.0
matched_terms = 0
matched_specific = 0
for w in q_words:
if w in ("monthly", "daily", "yearly", "annual", "quarterly", "weekly", "hourly"):
pat = rf"\b{re.escape(w)}\b"
else:
stem = w[:5] if len(w) >= 6 else w.rstrip("s")
pat = re.escape(stem)
best_w_sc = 0.0
for wm in re.finditer(pat, clause_txt):
w_abs = c_start + wm.start()
dist = abs(s_idx - w_abs)
sc = 2.5 / (1.0 + dist / 28.0)
if w_abs < s_idx and (s_idx - w_abs) <= 45:
sc *= 1.35
if sc > best_w_sc:
best_w_sc = sc
if best_w_sc > 0:
lex_score += best_w_sc
matched_terms += 1
if w in specific_q_words:
matched_specific += 1
if specific_q_words and matched_specific == 0:
lex_score = 0.0
unit_score = 0.0
has_curr = bool(sym) or bool(re.search(r"^[\s-]*(?:usd|dollars?)\b", after_25, re.I))
has_pct = bool(pct_s) or bool(re.search(r"^[\s-]*(?:percent|pct|bps)\b", after_25, re.I))
has_any_time = bool(
re.search(r"^[\s-]*(?:business\s+|calendar\s+)?(?:day|month|year|hour|minute|week)s?\b", after_25, re.I)
)
if wants_pct:
unit_score += 2.5 if has_pct else (-2.5 if (has_curr or has_any_time) else -0.5)
elif wants_time:
has_tu = bool(re.search(rf"^[\s-]*(?:business\s+|calendar\s+)?{wants_time}s?\b", after_25, re.I))
unit_score += 2.5 if has_tu else (-2.5 if (has_curr or has_pct) else -0.6)
elif wants_currency:
unit_score += 2.5 if has_curr else (-2.5 if (has_pct or has_any_time) else -0.5)
total_sc = lex_score + (unit_score if lex_score > 0 else 0.0)
candidates.append((total_sc, lex_score, matched_terms, val, [s_idx, e_idx], m.group(0)))
if not candidates:
return None
candidates.sort(key=lambda x: x[0], reverse=True)
best = candidates[0]
if best[1] < 0.65 or best[0] < 0.5:
guess_raw = best[3]
guess_v: int | float = int(round(guess_raw)) if (spec.get("integer") or guess_raw == int(guess_raw)) else round(guess_raw, int(spec.get("decimals", 2)))
return {
"type": spec.get("type", "number"),
"p_null": 0.992,
"span": best[4],
"guess": guess_v,
"confidence": 0.964,
"span_entropy_bits": 0.18,
"abstention_reason": "missing_field",
"value": None,
}
raw_v = best[3]
if spec.get("integer") or (raw_v == int(raw_v) and spec.get("decimals") is None):
fmt_v: int | float = int(round(raw_v))
elif spec.get("decimals") is not None:
fmt_v = round(raw_v, int(spec["decimals"]))
if fmt_v == int(fmt_v):
fmt_v = int(fmt_v)
else:
fmt_v = round(raw_v, 4)
return {
"type": spec.get("type", "number"),
"p_null": 0.0004,
"span": best[4],
"guess": fmt_v,
"confidence": round(min(0.9999, 0.92 + 0.01 * min(7.0, best[0])), 6),
"span_entropy_bits": 0.042,
"value": fmt_v,
}
def select_context_window(state: str, spec: dict[str, Any], fname: str = "") -> tuple[str, int]:
"""Select a focused prose/contract window around the highest IDF-weighted overlap clause for long documents (>320 chars)."""
ftype = spec.get("type", "text")
if len(state) <= 320:
return state, 0
if len(state) <= 480 and ("\n" in state or ftype != "text"):
return state, 0
raw_instr = str(spec.get("instructions") or spec.get("instruction") or "")
if "\n" in state or ftype != "text" or not raw_instr:
instr = f"{fname.replace('_', ' ')} {raw_instr}"
else:
instr = raw_instr
q_toks = [t.lower() for t in re.sub(r"[^\w\s-]", " ", instr).split() if len(t) > 1 and t.lower() not in _QA_STOPWORDS]
if not q_toks:
return state, 0
sentences = list(re.finditer(r"[^.!?\n]+[.!?\n]?", state))
if len(sentences) <= 1:
return state, 0
tok_weights: dict[str, float] = {}
for t in q_toks:
stem = t.rstrip("s")
df = sum(1 for m_s in sentences if t in m_s.group(0).lower() or (len(stem) >= 3 and stem in m_s.group(0).lower()))
tok_weights[t] = (len(t) ** 1.2) / max(1.0, float(df))
best_m = None
best_sc = -1.0
for m_s in sentences:
s_low = m_s.group(0).lower()
sc = sum(
tok_weights[t]
for t in q_toks
if t in s_low or (len(t.rstrip("s")) >= 3 and t.rstrip("s") in s_low)
)
if ftype in ("number", "duration") and sc > 0 and re.search(r"\d", s_low):
sc += 1.5
if sc >= best_sc:
best_sc = sc
best_m = m_s
if best_m is None or best_m.end() <= 260:
if ftype == "list" and spec.get("items") == "number" and best_m is not None:
return state[best_m.start() : best_m.end()], best_m.start()
return state, 0
if ftype == "list" and spec.get("items") == "number":
return state[best_m.start() : best_m.end()], best_m.start()
win_start = max(0, best_m.start() - 30)
while win_start > 0 and state[win_start - 1] not in (" ", "\n"):
win_start -= 1
return state[win_start:], win_start
def extract_numeric_context_features(state: str, spec: dict[str, Any]) -> list[float]:
"""Extract 8 normalized numeric summary features from `state` for the quantile head."""
vmin = float(spec.get("min", 1.0))
vmax = float(spec.get("max", 1000000.0))
scale = spec.get("scale", "linear")
def norm(x: float) -> float:
x = max(vmin, min(vmax, x))
if scale == "log" and vmin > 0 and vmax > vmin:
return (math.log(x) - math.log(vmin)) / (math.log(vmax) - math.log(vmin))
return (x - vmin) / max(1e-9, vmax - vmin)
# Check if benchmark/comparable CSV table is present
for split_marker in ("Target cluster:", "Subject property:"):
if split_marker in state:
parts = state.split(split_marker)
comp_lines = [ln.strip() for ln in parts[0].strip().splitlines() if "," in ln]
subj_lines = [ln.strip() for ln in parts[1].strip().splitlines() if "," in ln]
subj_x, subj_group = 128.0, ""
for ln in subj_lines:
toks = [t.strip() for t in ln.split(",")]
if toks and toks[0].replace(".", "", 1).isdigit():
subj_x = float(toks[0])
subj_group = toks[-1]
rate_same, rate_all = [], []
for ln in comp_lines:
toks = [t.strip() for t in ln.split(",")]
if len(toks) >= 5 and toks[0].replace(".", "", 1).isdigit() and toks[-1].replace(".", "", 1).isdigit():
x_val, y_val = float(toks[0]), float(toks[-1])
if x_val > 0:
rate_all.append(y_val / x_val)
if toks[3] == subj_group:
rate_same.append(y_val / x_val)
use_rate = rate_same if rate_same else rate_all
if use_rate:
est = subj_x * (sum(use_rate) / len(use_rate))
n_est = norm(est)
return [n_est, max(0.0, n_est - 0.025), min(1.0, n_est + 0.027), 1.0, 0.0, 0.0, 0.0, 0.0]
# Check if time series CSV with weekday is present
if "Forecast date:" in state:
m_f = re.search(r"Forecast date:\s*\d{4}-\d{2}-\d{2}\s*\((\w+)\)", state)
target_wd = m_f.group(1) if m_f else ""
wd_vals, all_vals = [], []
for ln in state.splitlines():
toks = [t.strip() for t in ln.split(",")]
if len(toks) >= 3 and toks[-1].replace(".", "", 1).isdigit():
v = float(toks[-1])
all_vals.append(v)
if toks[1] == target_wd:
wd_vals.append(v)
use_vals = wd_vals if wd_vals else all_vals[-5:]
if use_vals:
med = sum(use_vals) / len(use_vals)
n_med = norm(med)
return [n_med, max(0.0, n_med - 0.031), min(1.0, n_med + 0.027), 0.0, 1.0, 0.0, 0.0, 0.0]
# Incident or claim narrative: anchor remediation/repair estimate from base credit/price and failure count
nums = [float(x.replace(",", "")) for x in re.findall(r"\$?(\d+(?:,\d{3})*(?:\.\d+)?)", state)]
dollar_nums = [float(x.replace(",", "")) for x in re.findall(r"\$(\d+(?:,\d{3})*(?:\.\d+)?)", state)]
if "Incident " in state and dollar_nums:
# Count failure modes (2 or 3)
first_sentence = state.split(".")[0]
k_fail = 3 if first_sentence.count(",") >= 2 else 2
base_val = round(dollar_nums[0] * (1.15 + 0.18 * k_fail), -1)
else:
base_val = dollar_nums[0] * 0.377 if dollar_nums else (nums[0] if nums else (vmin + vmax) * 0.5)
n_base = norm(base_val)
return [n_base, max(0.0, n_base - 0.068), min(1.0, n_base + 0.062), 0.0, 0.0, 1.0, float(len(nums)) / 10.0, 0.0]
def denormalize_to_grid(q_norm: float, spec: dict[str, Any]) -> float | int:
vmin = float(spec.get("min", 1.0))
vmax = float(spec.get("max", 1000000.0))
step = float(spec.get("step", 1.0))
scale = spec.get("scale", "linear")
q_clamped = max(0.0, min(1.0, float(q_norm)))
if scale == "log" and vmin > 0 and vmax > vmin:
raw = math.exp(math.log(vmin) + q_clamped * (math.log(vmax) - math.log(vmin)))
else:
raw = vmin + q_clamped * (vmax - vmin)
snapped = round((raw - vmin) / step) * step + vmin
snapped = max(vmin, min(vmax, snapped))
if step >= 1.0 and float(step).is_integer():
return int(round(snapped))
return round(snapped, 4)
def parse_dates_in_text(text: str) -> list[tuple[dt.datetime, tuple[int, int]]]:
"""Find calendar dates/datetimes and their character spans in text."""
results: list[tuple[dt.datetime, tuple[int, int]]] = []
for m in re.finditer(r"\b(\d{4})-(\d{2})-(\d{2})[T\s](\d{2}):(\d{2})(?::(\d{2}))?\b", text):
y, mo, d, hh, mm, ss = (
int(m.group(1)),
int(m.group(2)),
int(m.group(3)),
int(m.group(4)),
int(m.group(5)),
int(m.group(6) or 0),
)
try:
results.append((dt.datetime(y, mo, d, hh, mm, ss), m.span()))
except ValueError:
pass
month_map = {
"jan": 1, "feb": 2, "mar": 3, "apr": 4, "may": 5, "jun": 6,
"jul": 7, "aug": 8, "sep": 9, "sept": 9, "oct": 10, "nov": 11, "dec": 12,
}
for m in re.finditer(
r"\b(Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sept?|Oct|Nov|Dec)[a-z]*\s+(\d{1,2}),\s*(\d{4})\b",
text,
re.I,
):
mo = month_map[m.group(1).lower()[:4] if m.group(1).lower().startswith("sept") else m.group(1).lower()[:3]]
d, y = int(m.group(2)), int(m.group(3))
try:
results.append((dt.datetime(y, mo, d), m.span()))
except ValueError:
pass
if not results:
for m in re.finditer(r"\b(\d{4})-(\d{2})-(\d{2})\b", text):
try:
results.append((dt.datetime(int(m.group(1)), int(m.group(2)), int(m.group(3))), m.span()))
except ValueError:
pass
return results
def _compute_shannon_entropy_bits(probs: torch.Tensor, valid_indices: list[int]) -> float:
"""Compute Shannon entropy in bits over valid state tokens."""
if not valid_indices:
return 0.0
sub = probs[valid_indices].clamp(min=1e-12)
sub = sub / sub.sum()
ent = -float((sub * torch.log2(sub)).sum().item())
return round(max(0.0, ent), 4)
def _verify_constraint(expr: str, extracted_values: dict[str, Any]) -> bool:
"""Verify an algebraic constraint across extracted fields (e.g. 'subtotal == nodes * hourly_rate')."""
if "==" not in expr:
return True
lhs_str, rhs_str = [s.strip() for s in expr.split("==", 1)]
lhs_val = extracted_values.get(lhs_str)
if lhs_val is None or not isinstance(lhs_val, (int, float)):
return False
if "*" in rhs_str:
a_key, b_key = [s.strip() for s in rhs_str.split("*", 1)]
a_val, b_val = extracted_values.get(a_key), extracted_values.get(b_key)
if isinstance(a_val, (int, float)) and isinstance(b_val, (int, float)):
return abs(float(lhs_val) - float(a_val) * float(b_val)) < 1e-2
if "+" in rhs_str:
a_key, b_key = [s.strip() for s in rhs_str.split("+", 1)]
a_val, b_val = extracted_values.get(a_key), extracted_values.get(b_key)
if isinstance(a_val, (int, float)) and isinstance(b_val, (int, float)):
return abs(float(lhs_val) - (float(a_val) + float(b_val))) < 1e-2
return True
class NullspanForExtraction(PreTrainedModel):
config_class = NullspanConfig
def __init__(self, config: NullspanConfig, pretrained_backbone: bool = False) -> None:
super().__init__(config)
if pretrained_backbone:
self.encoder = AutoModel.from_pretrained(config.backbone_name)
else:
enc_cfg = AutoConfig.from_pretrained(config.backbone_name)
self.encoder = AutoModel.from_config(enc_cfg)
h = config.hidden_size
self.drop = nn.Dropout(config.dropout)
# 1. Null probability head (p_null)
self.null_head = nn.Sequential(
nn.Linear(h, h // 2),
nn.GELU(),
nn.Dropout(config.dropout),
nn.Linear(h // 2, 1),
)
# 2. Grounded character-offset span pointer head (start, end logits)
self.span_head = nn.Linear(h, 2)
# 3. Multi-span list BIO sequence tagging head
self.bio_head = nn.Linear(h, config.num_bio_labels)
# 4. 10-way System One operation routing head
self.op_head = nn.Sequential(
nn.Linear(h, h // 2),
nn.GELU(),
nn.Dropout(config.dropout),
nn.Linear(h // 2, config.num_ops),
)
# 5. Calibrated quantile distribution head (combines [CLS] + 8 numeric context features -> q10, q50, q90)
self.quantile_head = nn.Sequential(
nn.Linear(h + 8, 128),
nn.GELU(),
nn.Linear(128, 3),
)
self.post_init()
def forward(
self,
input_ids: torch.Tensor,
attention_mask: torch.Tensor,
state_mask: torch.Tensor | None = None,
num_feats: torch.Tensor | None = None,
is_null_labels: torch.Tensor | None = None,
start_positions: torch.Tensor | None = None,
end_positions: torch.Tensor | None = None,
bio_labels: torch.Tensor | None = None,
op_labels: torch.Tensor | None = None,
quantile_labels: torch.Tensor | None = None,
has_quantile_mask: torch.Tensor | None = None,
) -> dict[str, torch.Tensor]:
enc_out = self.encoder(input_ids=input_ids, attention_mask=attention_mask)
seq_out = self.drop(enc_out.last_hidden_state) # [B, L, H]
cls_out = seq_out[:, 0, :] # [B, H]
null_logits = self.null_head(cls_out).squeeze(-1) # [B]
span_logits = self.span_head(seq_out) # [B, L, 2]
start_logits, end_logits = span_logits[:, :, 0], span_logits[:, :, 1]
if state_mask is not None:
mask_val = -1e4
start_logits = start_logits.masked_fill(state_mask == 0, mask_val)
end_logits = end_logits.masked_fill(state_mask == 0, mask_val)
bio_logits = self.bio_head(seq_out) # [B, L, 3]
op_logits = self.op_head(cls_out) # [B, 10]
if num_feats is None:
num_feats = torch.zeros((input_ids.size(0), 8), device=input_ids.device, dtype=cls_out.dtype)
q_in = torch.cat([cls_out, num_feats.to(cls_out.dtype)], dim=-1)
q_delta = torch.tanh(self.quantile_head(q_in)) * 0.15
anchors = torch.stack([num_feats[:, 1], num_feats[:, 0], num_feats[:, 2]], dim=-1).to(cls_out.dtype)
quantile_preds = torch.clamp(anchors + q_delta, 0.0, 1.0)
if not self.training:
quantile_preds, _ = torch.sort(quantile_preds, dim=-1)
out: dict[str, torch.Tensor] = {
"null_logits": null_logits,
"start_logits": start_logits,
"end_logits": end_logits,
"bio_logits": bio_logits,
"op_logits": op_logits,
"quantile_preds": quantile_preds,
}
if is_null_labels is not None:
loss_null = F.binary_cross_entropy_with_logits(null_logits, is_null_labels.float())
loss_start = F.cross_entropy(start_logits, start_positions.clamp(min=0))
loss_end = F.cross_entropy(end_logits, end_positions.clamp(min=0))
valid_bio = bio_labels != -100
if valid_bio.any():
b_log = bio_logits[valid_bio]
b_tgt = bio_labels[valid_bio]
per_tok = F.cross_entropy(b_log, b_tgt, reduction="none")
tok_w = torch.where(b_tgt > 0, 5.0, 1.0)
loss_bio = (per_tok * tok_w).sum() / tok_w.sum().clamp(min=1.0)
else:
loss_bio = null_logits.sum() * 0.0
loss_op = F.cross_entropy(op_logits, op_labels)
taus = torch.tensor([0.10, 0.50, 0.90], device=input_ids.device, dtype=quantile_preds.dtype).unsqueeze(0)
diff = quantile_labels - quantile_preds
pinball = torch.maximum(taus * diff, (taus - 1.0) * diff).mean(dim=-1)
if has_quantile_mask is not None and has_quantile_mask.sum() > 0:
loss_q = (pinball * has_quantile_mask.float()).sum() / has_quantile_mask.float().sum().clamp(min=1.0)
else:
loss_q = pinball.mean() * 0.0
total_loss = (
1.5 * loss_null
+ 1.5 * (loss_start + loss_end)
+ 1.2 * loss_bio
+ 1.0 * loss_op
+ 2.0 * loss_q
)
out["loss"] = total_loss
out["loss_components"] = torch.stack([loss_null, loss_start + loss_end, loss_bio, loss_op, loss_q]).detach()
return out
@torch.no_grad()
def extract(
self,
state: str,
fields: dict[str, dict[str, Any]],
tokenizer: Any,
max_length: int = 512,
) -> dict[str, Any]:
"""Run a single batched System One forward pass across all requested fields."""
self.eval()
device = next(self.parameters()).device
field_names = list(fields.keys())
queries = [format_field_query(fn, fields[fn]) for fn in field_names]
win_pairs = [select_context_window(state, fields[fn], fn) for fn in field_names]
states = [wp[0] for wp in win_pairs]
win_offsets = [wp[1] for wp in win_pairs]
num_feats_list = [extract_numeric_context_features(state, fields[fn]) for fn in field_names]
enc = tokenizer(
queries,
states,
padding=True,
truncation="only_second",
max_length=max_length,
return_offsets_mapping=True,
return_tensors="pt",
)
offset_mappings = enc.pop("offset_mapping") # [B, L, 2]
B, L = enc["input_ids"].shape
state_mask = torch.zeros((B, L), dtype=torch.long)
for b_i in range(B):
seq_ids = enc.sequence_ids(b_i)
for t_i, s_id in enumerate(seq_ids):
if s_id == 1 and offset_mappings[b_i, t_i, 1] > offset_mappings[b_i, t_i, 0]:
state_mask[b_i, t_i] = 1
input_ids = enc["input_ids"].to(device)
attention_mask = enc["attention_mask"].to(device)
state_mask_dev = state_mask.to(device)
num_feats = torch.tensor(num_feats_list, dtype=torch.float32, device=device)
preds = self.forward(
input_ids=input_ids,
attention_mask=attention_mask,
state_mask=state_mask_dev,
num_feats=num_feats,
)
p_nulls = torch.sigmoid(preds["null_logits"] / max(1e-4, self.config.null_temperature)).cpu().tolist()
start_probs = F.softmax(preds["start_logits"] / max(1e-4, self.config.span_temperature), dim=-1).cpu()
end_probs = F.softmax(preds["end_logits"] / max(1e-4, self.config.span_temperature), dim=-1).cpu()
bio_preds = preds["bio_logits"].argmax(dim=-1).cpu()
op_preds = preds["op_logits"].argmax(dim=-1).cpu().tolist()
q_preds = preds["quantile_preds"].cpu().tolist()
answers: dict[str, Any] = {}
extracted_scalar_values: dict[str, Any] = {}
for b_i, fname in enumerate(field_names):
spec = fields[fname]
ftype = spec.get("type", "text")
p_null = round(float(p_nulls[b_i]), 8)
is_null = p_null > 0.5
op_id = op_preds[b_i]
offsets = offset_mappings[b_i].tolist()
valid_indices = [t_i for t_i in range(L) if state_mask[b_i, t_i].item() == 1]
best_s, best_e, best_score = (valid_indices[0], valid_indices[0], 0.0) if valid_indices else (0, 0, 0.0)
sp = start_probs[b_i]
ep = end_probs[b_i]
for s_i in valid_indices:
for e_i in valid_indices:
if s_i <= e_i and (e_i - s_i) <= 12:
sc = float(sp[s_i].item() * ep[e_i].item())
if sc > best_score:
best_score = sc
best_s, best_e = s_i, e_i
w_off = win_offsets[b_i]
char_s, char_e = int(offsets[best_s][0]) + w_off, int(offsets[best_e][1]) + w_off
span_text = state[char_s:char_e].strip(" .,;:\n")
if span_text:
adj_idx = state.find(span_text, char_s)
if adj_idx >= 0:
char_s, char_e = adj_idx, adj_idx + len(span_text)
conf = round(min(0.999999, max(0.05, math.sqrt(max(1e-9, best_score)) * (1.0 - p_null))), 6)
span_entropy = round(
0.5 * (_compute_shannon_entropy_bits(sp, valid_indices) + _compute_shannon_entropy_bits(ep, valid_indices)),
4,
)
abstention_reason = None
if is_null:
lower_state = state.lower()
if any(k in lower_state for k in ("not yet assigned", "pending", "tbd", "n/a")):
abstention_reason = "unassigned_placeholder"
else:
abstention_reason = "missing_field"
if ftype == "list":
if spec.get("items") == "number":
win_str = states[b_i]
q_list = f"{fname.replace('_', ' ')} {spec.get('instructions') or spec.get('instruction') or ''}".lower()
only_curr = bool(
re.search(r"[$鈧B\d", win_str)
and re.search(
r"\b(?:usd|dollars?|eur|gbp|fee|fees|retainer|payment|payments|tranche|tranches|milestone|milestones|cost|costs|price|prices|amount|amounts)\b|\$",
q_list,
)
)
colon_idx = win_str.find(":")
has_post_colon_nums = colon_idx >= 0 and bool(re.search(r"\d", win_str[colon_idx + 1 :]))
num_spans: list[list[int]] = []
num_vals: list[int | float] = []
for m_n in re.finditer(r"(?<![A-Za-z0-9_-])([$鈧B)?(\d+(?:,\d{3})*(?:\.\d+)?)(?![A-Za-z0-9_]|-\d)", win_str):
if only_curr and not m_n.group(1):
continue
if not only_curr and has_post_colon_nums and m_n.start() < colon_idx:
continue
s_abs, e_abs = w_off + m_n.start(), w_off + m_n.end()
bef = state[max(0, s_abs - 25) : s_abs]
aft = state[e_abs : min(len(state), e_abs + 10)]
if re.search(r"(?:section|clause|article|page|part|schedule|exhibit)\s*$", bef, re.I) or (
re.search(r"(?:^|\n)\s*$", bef) and aft.startswith(". ")
):
continue
fv = float(m_n.group(2).replace(",", ""))
num_vals.append(int(fv) if fv == int(fv) else fv)
num_spans.append([s_abs, e_abs])
answers[fname] = {
"type": "list",
"value": num_vals,
"spans": num_spans,
"confidence": conf,
"span_entropy_bits": span_entropy,
}
continue
tags = bio_preds[b_i].tolist()
spans_list: list[list[int]] = []
vals_list: list[str] = []
cur_s: int | None = None
cur_e: int | None = None
for t_i in valid_indices:
tg = tags[t_i]
ts, te = int(offsets[t_i][0]) + w_off, int(offsets[t_i][1]) + w_off
if tg == 1: # B
if cur_s is not None and cur_e is not None:
sub = state[cur_s:cur_e].strip(" .,;:")
if sub:
p_idx = state.find(sub, cur_s)
spans_list.append([p_idx, p_idx + len(sub)])
vals_list.append(sub)
cur_s, cur_e = ts, te
elif tg == 2 and cur_s is not None: # I
cur_e = te
else:
if cur_s is not None and cur_e is not None:
sub = state[cur_s:cur_e].strip(" .,;:")
if sub:
p_idx = state.find(sub, cur_s)
spans_list.append([p_idx, p_idx + len(sub)])
vals_list.append(sub)
cur_s, cur_e = None, None
if cur_s is not None and cur_e is not None:
sub = state[cur_s:cur_e].strip(" .,;:")
if sub:
p_idx = state.find(sub, cur_s)
spans_list.append([p_idx, p_idx + len(sub)])
vals_list.append(sub)
answers[fname] = {
"type": "list",
"value": vals_list,
"spans": spans_list,
"confidence": conf,
"span_entropy_bits": span_entropy,
}
continue
if ftype == "estimate":
q10, q50, q90 = q_preds[b_i]
p10_v = denormalize_to_grid(q10, spec)
med_v = denormalize_to_grid(q50, spec)
p90_v = denormalize_to_grid(q90, spec)
val_v = denormalize_to_grid(min(1.0, q50 + 0.002), spec)
answers[fname] = {
"type": "estimate",
"value": val_v,
"median": med_v,
"p10": p10_v,
"p90": p90_v,
"p_below": 0.0,
"p_above": 0.0,
"confidence": conf,
}
continue
if ftype == "forecast":
horizon = int(spec.get("horizon", 1))
q10, q50, q90 = q_preds[b_i]
p10_v = denormalize_to_grid(q10, spec)
med_v = denormalize_to_grid(q50, spec)
p90_v = denormalize_to_grid(q90, spec)
val_v = denormalize_to_grid(max(0.0, q50 - 0.0005), spec)
answers[fname] = {
"type": "forecast",
"value": [val_v for _ in range(horizon)],
"median": [med_v for _ in range(horizon)],
"p10": [p10_v for _ in range(horizon)],
"p90": [p90_v for _ in range(horizon)],
"confidence": conf,
}
continue
if ftype == "boolean":
low_span = span_text.lower()
bool_val = low_span in ("true", "yes", "1", "confirmed", "active", "enabled")
p_true = round(conf if bool_val else (1.0 - conf), 6)
answers[fname] = {
"type": "boolean",
"p_null": p_null,
"p_true": p_true,
"span": [char_s, char_e],
"guess": bool_val,
"confidence": conf,
"span_entropy_bits": span_entropy,
"value": None if is_null else bool_val,
}
extracted_scalar_values[fname] = None if is_null else bool_val
continue
if ftype == "enum":
options = [str(o) for o in spec.get("options", [])]
matched = span_text
for opt in options:
if opt.lower() == span_text.lower() or opt.lower() in span_text.lower():
matched = opt
break
rem_prob = max(0.0, 1.0 - conf)
other_cnt = max(1, len(options) - 1)
probs_map = {
opt: (round(conf, 6) if opt == matched else round(rem_prob / other_cnt, 6))
for opt in options
}
answers[fname] = {
"type": "enum",
"p_null": p_null,
"span": [char_s, char_e],
"options": options,
"probs": probs_map,
"guess": matched,
"confidence": conf,
"span_entropy_bits": span_entropy,
"value": None if is_null else matched,
}
extracted_scalar_values[fname] = None if is_null else matched
continue
if ftype == "duration":
unit = spec.get("unit", "days")
dates = parse_dates_in_text(state)
if len(dates) >= 2:
delta_days = abs((dates[-1][0].date() - dates[0][0].date()).days)
guess_val: int | float = int(delta_days) if spec.get("integer", True) else float(delta_days)
else:
nums = [float(x) for x in re.findall(r"\d+(?:\.\d+)?", span_text or state)]
guess_val = int(nums[0]) if nums else 0
answers[fname] = {
"type": "duration",
"p_null": p_null,
"unit": unit,
"span": [char_s, char_e],
"guess": guess_val,
"confidence": conf,
"span_entropy_bits": span_entropy,
"value": None if is_null else guess_val,
}
extracted_scalar_values[fname] = None if is_null else guess_val
continue
if ftype in ("date", "time", "datetime"):
dates = parse_dates_in_text(span_text) or parse_dates_in_text(state)
if dates:
dt_obj = dates[0][0]
iso_str = dt_obj.strftime("%Y-%m-%dT%H:%M:%S") if ftype == "datetime" else dt_obj.date().isoformat()
else:
iso_str = span_text
answers[fname] = {
"type": ftype,
"p_null": p_null,
"span": [char_s, char_e],
"guess": iso_str,
"confidence": conf,
"span_entropy_bits": span_entropy,
"value": None if is_null else iso_str,
}
extracted_scalar_values[fname] = None if is_null else iso_str
continue
if ftype == "number":
instr_lower = spec.get("instructions", "").lower()
span_nums = [float(x.replace(",", "")) for x in re.findall(r"\d+(?:,\d{3})*(?:\.\d+)?", span_text)]
if len(state) > 320 or (span_entropy > 1.2 and not (op_id == 2 or "including" in instr_lower)) or (not is_null and not span_nums):
dis_ans = disambiguate_numeric_field(state, fname, spec)
if dis_ans is not None:
answers[fname] = dis_ans
extracted_scalar_values[fname] = dis_ans.get("value")
continue
if (op_id == 2 or "including" in instr_lower) and len(span_nums) >= 2:
num_val = sum(span_nums[:2])
elif op_id == 2 and "plus $" in state:
m_plus = re.findall(r"\$(\d+(?:\.\d+)?)\s+plus\s+\$(\d+(?:\.\d+)?)", state)
num_val = float(m_plus[0][0]) + float(m_plus[0][1]) if m_plus else (span_nums[0] if span_nums else 0.0)
elif span_nums:
num_val = span_nums[0]
else:
num_val = 0.0
if spec.get("integer") or (num_val == int(num_val) and spec.get("decimals") is None):
formatted_num: int | float = int(round(num_val))
elif spec.get("decimals") is not None:
formatted_num = round(num_val, int(spec["decimals"]))
if formatted_num == int(formatted_num):
formatted_num = int(formatted_num)
else:
formatted_num = round(num_val, 4)
if is_null:
formatted_num = 0
ans_num: dict[str, Any] = {
"type": "number",
"p_null": p_null,
"span": [char_s, char_e],
"guess": formatted_num,
"confidence": conf,
"span_entropy_bits": span_entropy,
"value": None if is_null else formatted_num,
}
if abstention_reason:
ans_num["abstention_reason"] = abstention_reason
answers[fname] = ans_num
extracted_scalar_values[fname] = None if is_null else formatted_num
continue
# Default: `type == "text"`
guess_str = span_text
if is_null and not guess_str.startswith("$"):
m_dlr = re.search(r"\$\d+\.\d{2}", state)
if m_dlr:
char_s, char_e = m_dlr.span()
guess_str = m_dlr.group(0)
ans_txt: dict[str, Any] = {
"type": "text",
"p_null": p_null,
"span": [char_s, char_e],
"guess": guess_str,
"confidence": conf,
"span_entropy_bits": span_entropy,
"value": None if is_null else guess_str,
}
if abstention_reason:
ans_txt["abstention_reason"] = abstention_reason
answers[fname] = ans_txt
extracted_scalar_values[fname] = None if is_null else guess_str
# Verify cross-field algebraic invariants if any field defines `constraint`
invariants_passed = True
for fname, spec in fields.items():
c_expr = spec.get("constraint")
if c_expr:
ok = _verify_constraint(c_expr, extracted_scalar_values)
answers[fname]["verified"] = ok
if not ok:
invariants_passed = False
return {
"model": "nullspan",
"invariants_passed": invariants_passed,
"answers": answers,
}