"""Block-level diff + significance (spec §19–20). compare(before_blocks, after_blocks, surface=…, before_text=…, after_text=…, structured_delta=…, history=…) -> BlockDiff Matching: exact block `key` → simhash near-match (Hamming ≤ `NEAR_HAMMING`) → rapidfuzz ratio ≥ `MODIFIED_MIN_RATIO` for modified blocks; everything else is added/removed. Moves are matched blocks whose relative order changed (LIS on positions). Significance ∈ [0, 1] is deterministic and explained by `reasons`: weighted changed share of *content* blocks (nav/footer/header do not count), surface importance, boosts for typed deltas (prices, people, jobs, locations, products, news), novelty from the sensor's history, and hard caps for classic noise (footer-only churn, tiny edits, counters). Bands (spec §20): < 0.20 noise · < 0.40 minor · < 0.65 meaningful · < 0.85 major · ≥ 0.85 critical. """ from __future__ import annotations from collections.abc import Mapping from typing import Any from rapidfuzz import fuzz from rapidfuzz.distance import Indel from companyatlas.sdk.models import Block, BlockDelta, BlockDiff from companyatlas.sdk.normalize import LOW_VALUE_KINDS, hamming, normalized_text from companyatlas.taxonomy import SURFACE_IMPORTANCE DIFF_VERSION = "diff-v1" NEAR_HAMMING = 6 # simhash distance considered "same block, edited" MODIFIED_MIN_RATIO = 0.55 # rapidfuzz ratio (0–1) for a near-match to count as modified rather than add+remove TINY_EDIT_SIMILARITY = 0.97 # modified blocks at/above this similarity are cosmetic BLOCK_LEN_CAP = 1500 # a single giant block must not dominate the share TEXT_COMPARE_CAP = 60_000 # characters compared for text_delta_ratio # Typed-delta floors: when the pipeline reconciled real entities, the change is at least this significant (spec §20 "affected entities"). TYPED_FLOORS: dict[str, float] = { "plans.price_changed": 0.70, "plans.added": 0.60, "plans.removed": 0.60, "people.added_executive": 0.68, "people.removed_executive": 0.68, "people.added": 0.50, "people.removed": 0.50, "people.title_changed": 0.55, "jobs.added": 0.45, "jobs.removed": 0.42, "locations.new_countries": 0.70, "locations.added": 0.55, "locations.removed": 0.52, "products.added": 0.55, "products.removed": 0.55, "news.added": 0.45, "meta.title_changed": 0.30, } JOBS_SCALE_FLOOR = 0.62 # many jobs added/removed at once (≥ JOBS_SCALE_COUNT or ≥ 25 % of open jobs) JOBS_SCALE_COUNT = 10 NOVELTY_STABLE_RUNS = 20 # consecutive unchanged runs before a change counts as "novel" NOVELTY_BOOST = 1.10 CHURN_RATE = 0.5 # changes per observation above which the page is considered volatile CHURN_PENALTY = 0.85 CONTENT_BASE, CONTENT_SPAN, CONTENT_EXP = 0.12, 0.72, 0.7 # pure content share maps to [0.12, 0.84] — critical needs typed deltas / novelty NOISE_CAP = 0.19 LOW_VALUE_CAP = 0.15 def _content_len(b: Block) -> float: return float(min(len(b.text), BLOCK_LEN_CAP)) * max(0.05, b.weight) def _delta(b: Block, *, before: str | None, after: str | None, similarity: float | None = None) -> BlockDelta: return BlockDelta(key=b.key, kind=b.kind, path=b.path, before=before, after=after, weight=b.weight, similarity=similarity) def _ratio(a: str, b: str) -> float: if not a and not b: return 1.0 return fuzz.ratio(normalized_text(a)[:BLOCK_LEN_CAP * 2], normalized_text(b)[:BLOCK_LEN_CAP * 2]) / 100.0 def _lis_positions(seq: list[int]) -> set[int]: """Indices (into seq) of one longest strictly increasing subsequence — items outside it moved.""" if not seq: return set() import bisect tails: list[int] = [] tails_idx: list[int] = [] prev = [-1] * len(seq) for i, v in enumerate(seq): pos = bisect.bisect_left(tails, v) if pos == len(tails): tails.append(v) tails_idx.append(i) else: tails[pos] = v tails_idx[pos] = i prev[i] = tails_idx[pos - 1] if pos > 0 else -1 out: set[int] = set() k = tails_idx[-1] while k != -1: out.add(k) k = prev[k] return out def match_blocks(before: list[Block], after: list[Block]) -> tuple[list[tuple[Block, Block]], list[Block], list[Block]]: """Return (pairs, removed, added). Pairs include exact-key matches and near matches (simhash / fuzzy).""" by_key_after: dict[str, Block] = {} for b in after: by_key_after.setdefault(b.key, b) pairs: list[tuple[Block, Block]] = [] used_after: set[int] = set() unmatched_before: list[Block] = [] for b in before: a = by_key_after.get(b.key) if a is not None and id(a) not in used_after: pairs.append((b, a)) used_after.add(id(a)) else: unmatched_before.append(b) remaining_after = [a for a in after if id(a) not in used_after] # near matches: same kind, closest simhash within NEAR_HAMMING, tie-break by same path then fuzzy ratio still_before: list[Block] = [] for b in unmatched_before: best: tuple[float, int, Block] | None = None for a in remaining_after: if a.kind != b.kind or id(a) in used_after: continue d = hamming(a.simhash, b.simhash) if (a.simhash and b.simhash) else 64 if d <= NEAR_HAMMING: score = (1.0 if a.path == b.path else 0.0, -d) if best is None or score > (best[0], -best[1]): best = (score[0], d, a) if best is None: # fuzzy fallback for short/edited blocks (bounded: only blocks of the same kind and path) cands = [a for a in remaining_after if a.kind == b.kind and a.path == b.path and id(a) not in used_after] best_r = 0.0 best_a: Block | None = None for a in cands[:60]: r = _ratio(b.text, a.text) if r > best_r: best_r, best_a = r, a if best_a is not None and best_r >= MODIFIED_MIN_RATIO: best = (0.0, 64, best_a) if best is not None: pairs.append((b, best[2])) used_after.add(id(best[2])) else: still_before.append(b) added = [a for a in after if id(a) not in used_after] return pairs, still_before, added def compare(before_blocks: list[Block], after_blocks: list[Block], *, surface: str, before_text: str = "", after_text: str = "", structured_delta: Mapping[str, Any] | None = None, history: Mapping[str, Any] | None = None) -> BlockDiff: sd = structured_delta or {} hist = history or {} diff = BlockDiff() pairs, removed, added = match_blocks(before_blocks, after_blocks) modified_pairs: list[tuple[Block, Block, float]] = [] for b, a in pairs: if b.hash == a.hash: continue sim = _ratio(b.text, a.text) modified_pairs.append((b, a, sim)) # moved: exact matches whose order is not increasing order_after = {id(a): a.order for _b, a in pairs} seq = [order_after[id(a)] for _b, a in sorted(pairs, key=lambda p: p[0].order)] keep = _lis_positions(seq) sorted_pairs = sorted(pairs, key=lambda p: p[0].order) for i, (b, _a) in enumerate(sorted_pairs): if i not in keep: diff.moved.append(b.key) diff.added = [_delta(a, before=None, after=a.text) for a in added] diff.removed = [_delta(b, before=b.text, after=None) for b in removed] diff.modified = [_delta(a, before=b.text, after=a.text, similarity=round(sim, 4)) for b, a, sim in modified_pairs] # ---------------------------------------------------------------- weighted changed share (content blocks only) total_before = sum(_content_len(b) for b in before_blocks if b.kind not in LOW_VALUE_KINDS) total_after = sum(_content_len(b) for b in after_blocks if b.kind not in LOW_VALUE_KINDS) total = max(total_before, total_after, 1.0) changed = 0.0 low_value_changed = 0.0 for a in added: if a.kind in LOW_VALUE_KINDS: low_value_changed += _content_len(a) else: changed += _content_len(a) for b in removed: if b.kind in LOW_VALUE_KINDS: low_value_changed += _content_len(b) else: changed += _content_len(b) for b, a, sim in modified_pairs: amount = max(_content_len(a), _content_len(b)) * (1.0 - sim) if a.kind in LOW_VALUE_KINDS: low_value_changed += amount else: changed += amount changed_share = min(1.0, changed / total) diff.similarity = round(max(0.0, 1.0 - changed_share), 4) # ---------------------------------------------------------------- text delta ratio if before_text or after_text: nb = normalized_text(before_text)[:TEXT_COMPARE_CAP] na = normalized_text(after_text)[:TEXT_COMPARE_CAP] diff.text_delta_ratio = round(Indel.normalized_distance(nb, na), 4) if (nb or na) else 0.0 else: diff.text_delta_ratio = round(changed_share, 4) # ---------------------------------------------------------------- significance reasons: list[str] = [] content_deltas = [d for d in (diff.added + diff.removed + diff.modified) if d.kind not in LOW_VALUE_KINDS] low_value_deltas = [d for d in (diff.added + diff.removed + diff.modified) if d.kind in LOW_VALUE_KINDS] typed_keys = _typed_keys(sd) if not content_deltas and not low_value_deltas and not typed_keys and diff.text_delta_ratio < 0.001: diff.significance = 0.0 diff.reasons = ["identical"] if not diff.moved else ["blocks reordered only"] return diff importance = float(SURFACE_IMPORTANCE.get(surface, 0.3)) importance_factor = 0.7 + 0.3 * importance if content_deltas: base = CONTENT_BASE + CONTENT_SPAN * (changed_share ** CONTENT_EXP) reasons.append(f"content changed share {changed_share:.3f} over {len(content_deltas)} block(s)") elif typed_keys: base = 0.2 reasons.append("typed delta without block-level content change") else: base = 0.05 reasons.append("only nav/header/footer blocks changed") sig = base * importance_factor reasons.append(f"surface {surface} importance {importance:.2f} (×{importance_factor:.2f})") # typed floors floor = 0.0 floor_reason = "" for key, value in typed_keys.items(): f = TYPED_FLOORS.get(key, 0.0) if key in ("jobs.added", "jobs.removed"): open_before = int(sd.get("jobs", {}).get("open_before") or 0) if value >= JOBS_SCALE_COUNT or (open_before and value / open_before >= 0.25): f = max(f, JOBS_SCALE_FLOOR) if f > floor: floor, floor_reason = f, f"{key}={value}" if floor > 0: # a typed delta lifts the score to its floor and still rewards larger content changes above it sig = max(sig, floor + 0.15 * min(1.0, changed_share)) reasons.append(f"typed delta {floor_reason} → floor {floor:.2f}") # novelty / churn from sensor history unchanged_runs = int(hist.get("consecutive_unchanged") or 0) observations = int(hist.get("observation_count") or 0) changes = int(hist.get("change_count") or 0) if unchanged_runs >= NOVELTY_STABLE_RUNS: sig *= NOVELTY_BOOST reasons.append(f"novel after {unchanged_runs} unchanged runs (×{NOVELTY_BOOST})") elif observations >= 6 and changes / max(1, observations) > CHURN_RATE and not typed_keys: sig *= CHURN_PENALTY reasons.append(f"volatile page ({changes}/{observations} runs changed, ×{CHURN_PENALTY})") # noise caps (never override typed floors) if not typed_keys: if not content_deltas: sig = min(sig, LOW_VALUE_CAP) reasons.append("nav/footer-only churn → noise cap") else: only_tiny = all(d.similarity is not None and d.similarity >= TINY_EDIT_SIMILARITY for d in content_deltas) if only_tiny: sig = min(sig, NOISE_CAP) reasons.append("only cosmetic edits (similarity ≥ 0.97) → noise cap") elif changed_share < 0.01 and len(content_deltas) <= 2 and diff.text_delta_ratio < 0.01: sig = min(sig, NOISE_CAP) reasons.append("negligible share (< 1 %) in ≤ 2 blocks → noise cap") elif _all_noise_tokens(content_deltas): sig = min(sig, NOISE_CAP) reasons.append("changes limited to dates/counters → noise cap") diff.significance = round(max(0.0, min(1.0, sig)), 4) diff.reasons = reasons return diff def _typed_keys(sd: Mapping[str, Any]) -> dict[str, int]: """Flatten a StructuredDelta into {"jobs.added": n, "plans.price_changed": n, "people.added_executive": n, …} (non-zero only).""" out: dict[str, int] = {} for group in ("jobs", "people", "products", "plans", "locations", "news"): g = sd.get(group) or {} if not isinstance(g, Mapping): continue for k in ("added", "removed", "price_changed", "title_changed", "new_countries"): v = g.get(k) if isinstance(v, list) and v: out[f"{group}.{k}"] = len(v) if group == "people" and k in ("added", "removed"): execs = sum(1 for p in v if isinstance(p, Mapping) and (p.get("is_executive") or p.get("role_category") in ("ceo", "cfo", "cto", "coo", "founder", "president", "chair"))) if execs: out[f"people.{k}_executive"] = execs meta = sd.get("meta") or {} if isinstance(meta, Mapping) and meta.get("title_changed"): out["meta.title_changed"] = 1 return out def _all_noise_tokens(deltas: list[BlockDelta]) -> bool: """True when every modified block is identical after noise normalisation (dates, counters…) — belt and braces: such blocks normally share the same hash and never reach the delta list, but fuzzy-matched blocks with different keys can.""" for d in deltas: if d.before is None or d.after is None: return False if normalized_text(d.before) != normalized_text(d.after): return False return bool(deltas) __all__ = ["DIFF_VERSION", "NEAR_HAMMING", "TYPED_FLOORS", "compare", "match_blocks"]