SPB Git forge
28commits 1branches 0releases
7.7 MBsize
maindefault branch
10 days agolast push
Python 66.3% TypeScript 22.7% JavaScript 8.6% HTML 1.4% CSS 0.7%
14.1 KB · 308 lines python
Raw Blame History
1"""Block-level diff + significance (spec §19–20).23    compare(before_blocks, after_blocks, surface=…, before_text=…, after_text=…, structured_delta=…, history=…) -> BlockDiff45Matching: exact block `key` → simhash near-match (Hamming ≤ `NEAR_HAMMING`) → rapidfuzz ratio ≥ `MODIFIED_MIN_RATIO` for modified6blocks; everything else is added/removed. Moves are matched blocks whose relative order changed (LIS on positions).78Significance ∈ [0, 1] is deterministic and explained by `reasons`: weighted changed share of *content* blocks (nav/footer/header do not9count), surface importance, boosts for typed deltas (prices, people, jobs, locations, products, news), novelty from the sensor's10history, and hard caps for classic noise (footer-only churn, tiny edits, counters). Bands (spec §20): < 0.20 noise · < 0.40 minor11· < 0.65 meaningful · < 0.85 major · ≥ 0.85 critical.12"""13from __future__ import annotations1415from collections.abc import Mapping16from typing import Any1718from rapidfuzz import fuzz19from rapidfuzz.distance import Indel2021from companyatlas.sdk.models import Block, BlockDelta, BlockDiff22from companyatlas.sdk.normalize import LOW_VALUE_KINDS, hamming, normalized_text23from companyatlas.taxonomy import SURFACE_IMPORTANCE2425DIFF_VERSION = "diff-v1"2627NEAR_HAMMING = 6                 # simhash distance considered "same block, edited"28MODIFIED_MIN_RATIO = 0.55        # rapidfuzz ratio (0–1) for a near-match to count as modified rather than add+remove29TINY_EDIT_SIMILARITY = 0.97      # modified blocks at/above this similarity are cosmetic30BLOCK_LEN_CAP = 1500             # a single giant block must not dominate the share31TEXT_COMPARE_CAP = 60_000        # characters compared for text_delta_ratio3233# Typed-delta floors: when the pipeline reconciled real entities, the change is at least this significant (spec §20 "affected entities").34TYPED_FLOORS: dict[str, float] = {35    "plans.price_changed": 0.70, "plans.added": 0.60, "plans.removed": 0.60,36    "people.added_executive": 0.68, "people.removed_executive": 0.68, "people.added": 0.50, "people.removed": 0.50, "people.title_changed": 0.55,37    "jobs.added": 0.45, "jobs.removed": 0.42,38    "locations.new_countries": 0.70, "locations.added": 0.55, "locations.removed": 0.52,39    "products.added": 0.55, "products.removed": 0.55,40    "news.added": 0.45,41    "meta.title_changed": 0.30,42}43JOBS_SCALE_FLOOR = 0.62          # many jobs added/removed at once (≥ JOBS_SCALE_COUNT or ≥ 25 % of open jobs)44JOBS_SCALE_COUNT = 1045NOVELTY_STABLE_RUNS = 20         # consecutive unchanged runs before a change counts as "novel"46NOVELTY_BOOST = 1.1047CHURN_RATE = 0.5                 # changes per observation above which the page is considered volatile48CHURN_PENALTY = 0.8549CONTENT_BASE, CONTENT_SPAN, CONTENT_EXP = 0.12, 0.72, 0.7   # pure content share maps to [0.12, 0.84] — critical needs typed deltas / novelty50NOISE_CAP = 0.1951LOW_VALUE_CAP = 0.15525354def _content_len(b: Block) -> float:55    return float(min(len(b.text), BLOCK_LEN_CAP)) * max(0.05, b.weight)565758def _delta(b: Block, *, before: str | None, after: str | None, similarity: float | None = None) -> BlockDelta:59    return BlockDelta(key=b.key, kind=b.kind, path=b.path, before=before, after=after, weight=b.weight, similarity=similarity)606162def _ratio(a: str, b: str) -> float:63    if not a and not b:64        return 1.065    return fuzz.ratio(normalized_text(a)[:BLOCK_LEN_CAP * 2], normalized_text(b)[:BLOCK_LEN_CAP * 2]) / 100.0666768def _lis_positions(seq: list[int]) -> set[int]:69    """Indices (into seq) of one longest strictly increasing subsequence — items outside it moved."""70    if not seq:71        return set()72    import bisect7374    tails: list[int] = []75    tails_idx: list[int] = []76    prev = [-1] * len(seq)77    for i, v in enumerate(seq):78        pos = bisect.bisect_left(tails, v)79        if pos == len(tails):80            tails.append(v)81            tails_idx.append(i)82        else:83            tails[pos] = v84            tails_idx[pos] = i85        prev[i] = tails_idx[pos - 1] if pos > 0 else -186    out: set[int] = set()87    k = tails_idx[-1]88    while k != -1:89        out.add(k)90        k = prev[k]91    return out929394def match_blocks(before: list[Block], after: list[Block]) -> tuple[list[tuple[Block, Block]], list[Block], list[Block]]:95    """Return (pairs, removed, added). Pairs include exact-key matches and near matches (simhash / fuzzy)."""96    by_key_after: dict[str, Block] = {}97    for b in after:98        by_key_after.setdefault(b.key, b)99    pairs: list[tuple[Block, Block]] = []100    used_after: set[int] = set()101    unmatched_before: list[Block] = []102    for b in before:103        a = by_key_after.get(b.key)104        if a is not None and id(a) not in used_after:105            pairs.append((b, a))106            used_after.add(id(a))107        else:108            unmatched_before.append(b)109    remaining_after = [a for a in after if id(a) not in used_after]110    # near matches: same kind, closest simhash within NEAR_HAMMING, tie-break by same path then fuzzy ratio111    still_before: list[Block] = []112    for b in unmatched_before:113        best: tuple[float, int, Block] | None = None114        for a in remaining_after:115            if a.kind != b.kind or id(a) in used_after:116                continue117            d = hamming(a.simhash, b.simhash) if (a.simhash and b.simhash) else 64118            if d <= NEAR_HAMMING:119                score = (1.0 if a.path == b.path else 0.0, -d)120                if best is None or score > (best[0], -best[1]):121                    best = (score[0], d, a)122        if best is None:123            # fuzzy fallback for short/edited blocks (bounded: only blocks of the same kind and path)124            cands = [a for a in remaining_after if a.kind == b.kind and a.path == b.path and id(a) not in used_after]125            best_r = 0.0126            best_a: Block | None = None127            for a in cands[:60]:128                r = _ratio(b.text, a.text)129                if r > best_r:130                    best_r, best_a = r, a131            if best_a is not None and best_r >= MODIFIED_MIN_RATIO:132                best = (0.0, 64, best_a)133        if best is not None:134            pairs.append((b, best[2]))135            used_after.add(id(best[2]))136        else:137            still_before.append(b)138    added = [a for a in after if id(a) not in used_after]139    return pairs, still_before, added140141142def compare(before_blocks: list[Block], after_blocks: list[Block], *, surface: str, before_text: str = "", after_text: str = "",143            structured_delta: Mapping[str, Any] | None = None, history: Mapping[str, Any] | None = None) -> BlockDiff:144    sd = structured_delta or {}145    hist = history or {}146    diff = BlockDiff()147    pairs, removed, added = match_blocks(before_blocks, after_blocks)148149    modified_pairs: list[tuple[Block, Block, float]] = []150    for b, a in pairs:151        if b.hash == a.hash:152            continue153        sim = _ratio(b.text, a.text)154        modified_pairs.append((b, a, sim))155    # moved: exact matches whose order is not increasing156    order_after = {id(a): a.order for _b, a in pairs}157    seq = [order_after[id(a)] for _b, a in sorted(pairs, key=lambda p: p[0].order)]158    keep = _lis_positions(seq)159    sorted_pairs = sorted(pairs, key=lambda p: p[0].order)160    for i, (b, _a) in enumerate(sorted_pairs):161        if i not in keep:162            diff.moved.append(b.key)163164    diff.added = [_delta(a, before=None, after=a.text) for a in added]165    diff.removed = [_delta(b, before=b.text, after=None) for b in removed]166    diff.modified = [_delta(a, before=b.text, after=a.text, similarity=round(sim, 4)) for b, a, sim in modified_pairs]167168    # ---------------------------------------------------------------- weighted changed share (content blocks only)169    total_before = sum(_content_len(b) for b in before_blocks if b.kind not in LOW_VALUE_KINDS)170    total_after = sum(_content_len(b) for b in after_blocks if b.kind not in LOW_VALUE_KINDS)171    total = max(total_before, total_after, 1.0)172    changed = 0.0173    low_value_changed = 0.0174    for a in added:175        if a.kind in LOW_VALUE_KINDS:176            low_value_changed += _content_len(a)177        else:178            changed += _content_len(a)179    for b in removed:180        if b.kind in LOW_VALUE_KINDS:181            low_value_changed += _content_len(b)182        else:183            changed += _content_len(b)184    for b, a, sim in modified_pairs:185        amount = max(_content_len(a), _content_len(b)) * (1.0 - sim)186        if a.kind in LOW_VALUE_KINDS:187            low_value_changed += amount188        else:189            changed += amount190    changed_share = min(1.0, changed / total)191    diff.similarity = round(max(0.0, 1.0 - changed_share), 4)192193    # ---------------------------------------------------------------- text delta ratio194    if before_text or after_text:195        nb = normalized_text(before_text)[:TEXT_COMPARE_CAP]196        na = normalized_text(after_text)[:TEXT_COMPARE_CAP]197        diff.text_delta_ratio = round(Indel.normalized_distance(nb, na), 4) if (nb or na) else 0.0198    else:199        diff.text_delta_ratio = round(changed_share, 4)200201    # ---------------------------------------------------------------- significance202    reasons: list[str] = []203    content_deltas = [d for d in (diff.added + diff.removed + diff.modified) if d.kind not in LOW_VALUE_KINDS]204    low_value_deltas = [d for d in (diff.added + diff.removed + diff.modified) if d.kind in LOW_VALUE_KINDS]205    typed_keys = _typed_keys(sd)206207    if not content_deltas and not low_value_deltas and not typed_keys and diff.text_delta_ratio < 0.001:208        diff.significance = 0.0209        diff.reasons = ["identical"] if not diff.moved else ["blocks reordered only"]210        return diff211212    importance = float(SURFACE_IMPORTANCE.get(surface, 0.3))213    importance_factor = 0.7 + 0.3 * importance214    if content_deltas:215        base = CONTENT_BASE + CONTENT_SPAN * (changed_share ** CONTENT_EXP)216        reasons.append(f"content changed share {changed_share:.3f} over {len(content_deltas)} block(s)")217    elif typed_keys:218        base = 0.2219        reasons.append("typed delta without block-level content change")220    else:221        base = 0.05222        reasons.append("only nav/header/footer blocks changed")223    sig = base * importance_factor224    reasons.append(f"surface {surface} importance {importance:.2f} (×{importance_factor:.2f})")225226    # typed floors227    floor = 0.0228    floor_reason = ""229    for key, value in typed_keys.items():230        f = TYPED_FLOORS.get(key, 0.0)231        if key in ("jobs.added", "jobs.removed"):232            open_before = int(sd.get("jobs", {}).get("open_before") or 0)233            if value >= JOBS_SCALE_COUNT or (open_before and value / open_before >= 0.25):234                f = max(f, JOBS_SCALE_FLOOR)235        if f > floor:236            floor, floor_reason = f, f"{key}={value}"237    if floor > 0:238        # a typed delta lifts the score to its floor and still rewards larger content changes above it239        sig = max(sig, floor + 0.15 * min(1.0, changed_share))240        reasons.append(f"typed delta {floor_reason} → floor {floor:.2f}")241242    # novelty / churn from sensor history243    unchanged_runs = int(hist.get("consecutive_unchanged") or 0)244    observations = int(hist.get("observation_count") or 0)245    changes = int(hist.get("change_count") or 0)246    if unchanged_runs >= NOVELTY_STABLE_RUNS:247        sig *= NOVELTY_BOOST248        reasons.append(f"novel after {unchanged_runs} unchanged runs (×{NOVELTY_BOOST})")249    elif observations >= 6 and changes / max(1, observations) > CHURN_RATE and not typed_keys:250        sig *= CHURN_PENALTY251        reasons.append(f"volatile page ({changes}/{observations} runs changed, ×{CHURN_PENALTY})")252253    # noise caps (never override typed floors)254    if not typed_keys:255        if not content_deltas:256            sig = min(sig, LOW_VALUE_CAP)257            reasons.append("nav/footer-only churn → noise cap")258        else:259            only_tiny = all(d.similarity is not None and d.similarity >= TINY_EDIT_SIMILARITY for d in content_deltas)260            if only_tiny:261                sig = min(sig, NOISE_CAP)262                reasons.append("only cosmetic edits (similarity ≥ 0.97) → noise cap")263            elif changed_share < 0.01 and len(content_deltas) <= 2 and diff.text_delta_ratio < 0.01:264                sig = min(sig, NOISE_CAP)265                reasons.append("negligible share (< 1 %) in ≤ 2 blocks → noise cap")266            elif _all_noise_tokens(content_deltas):267                sig = min(sig, NOISE_CAP)268                reasons.append("changes limited to dates/counters → noise cap")269    diff.significance = round(max(0.0, min(1.0, sig)), 4)270    diff.reasons = reasons271    return diff272273274def _typed_keys(sd: Mapping[str, Any]) -> dict[str, int]:275    """Flatten a StructuredDelta into {"jobs.added": n, "plans.price_changed": n, "people.added_executive": n, …} (non-zero only)."""276    out: dict[str, int] = {}277    for group in ("jobs", "people", "products", "plans", "locations", "news"):278        g = sd.get(group) or {}279        if not isinstance(g, Mapping):280            continue281        for k in ("added", "removed", "price_changed", "title_changed", "new_countries"):282            v = g.get(k)283            if isinstance(v, list) and v:284                out[f"{group}.{k}"] = len(v)285                if group == "people" and k in ("added", "removed"):286                    execs = sum(1 for p in v if isinstance(p, Mapping) and (p.get("is_executive") or p.get("role_category") in287                                ("ceo", "cfo", "cto", "coo", "founder", "president", "chair")))288                    if execs:289                        out[f"people.{k}_executive"] = execs290    meta = sd.get("meta") or {}291    if isinstance(meta, Mapping) and meta.get("title_changed"):292        out["meta.title_changed"] = 1293    return out294295296def _all_noise_tokens(deltas: list[BlockDelta]) -> bool:297    """True when every modified block is identical after noise normalisation (dates, counters…) — belt and braces: such blocks298    normally share the same hash and never reach the delta list, but fuzzy-matched blocks with different keys can."""299    for d in deltas:300        if d.before is None or d.after is None:301            return False302        if normalized_text(d.before) != normalized_text(d.after):303            return False304    return bool(deltas)305306307__all__ = ["DIFF_VERSION", "NEAR_HAMMING", "TYPED_FLOORS", "compare", "match_blocks"]308