Files
AgentLab/backend/app/data/value_drift.py
T
ThearchyHelios c95e5b4e84 W3-a 取值观察:剖析引擎的四类观察检查、比较器、撤销「已列全」(next-round 03 B2)
码值出现新取值、比例剧变、业务日期断档、某天没数据,以前都看不到:确认过的码值列不再取分布,大表只看物理顺序最老的
前 10 万行,「已列全」过期以后 SQL 检查还照它提示。现在:

- 剖析引擎加四类观察检查(data/catalog_profile_observe.py):rows(行数,用剖析开头那条估行数的结果)、codes_observe
  (所有码值列,包括人工确认过、标了已列全的)、freshness(已确认或已验证的业务日期的最新日期)、daily_volume(最近 35
  天按天计数)。计入每张表的预算、止损和留痕,每条观察记口径 basis 和它出自哪几条查询;遮罩列不进任何观察查询。
- 大表口径照目录-D7:有确认的业务日期且该列打头有索引看 35 天窗口;PG / Oracle 没有就块采样(比例按表的大小取一档,
  不跨档比);MySQL / SQLite 没有就按存储顺序的前 N 行,照实写口径。最新业务日期、按日行数在大表上只在有索引时才查。
  SqlDialect 加 max_date_sql(有效区间里的最大日期),方言金样跟着加了两条。
- data/value_drift.py 纯函数比较器:新码值(已列全的任何新值、没列全的要占 0.5% 以上且有历史)、PSI 剧变(≥0.25
  warning、0.1–0.25 info,两次都要 1,000 行)、断档(超过 max(2×中位间隔, 3 天))、缺天(以前的窗口没有过)、量跌
  (不到前 28 天中位数的 30%)、行数跌 30% / 涨 3 倍(估算值要连续两次)。只和同口径的历史比。
- 观察记进 table_observations(每张表每种观察留最近 26 条),比出的取值变化进 source_changes,和这张表的剖析结果、目录在
  同一个事务里。机器唯一写进目录的事:数据证明码值表不再列全时撤销「已列全」(目录版本 +1,署名「取值观察」,值、含义、
  状态都不动),SQL 检查随之不再按旧码值表提示 unknown_code。
- 剖析任务的范围加 observe(观察到期的有目录的表,只观察)和 maintenance(定时维护:需要剖析的 + 观察到期的);人点的
  「需要剖析 / 全部 / 指定的表」照旧不观察。只观察的那一项不算「上次剖析」(不顶掉表清单的上次剖析和给助手的剖析事实)。
  任务的设置快照里记 trigger / fire_at / schedule_id(剖析任务表没有这几列)。
- 观察结论(最新业务日期、更新的行数)经 last_facts → schema_view.profile_lines 和剖析事实同一节进 look、db_schema、
  /schema、表助手(X7),遮罩在渲染那一刻再核。

测试:test_value_drift(阈值两侧、口径不同不比)、test_catalog_observe(四类观察与口径、撤销已列全与 SQL 检查、去重、
遮罩不进查询、大表窗口与前 N 行、PG / Oracle 采样 SQL、观察不算上次剖析、进表详情)。
2026-10-16 16:40:41 +08:00

264 lines
12 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""取值观察的比较器(docs/design/next-round/03-数据接入与目录维护.md B2 ④):纯函数,不连库。
**为什么要有它。** 确认过的码值列以前不再取分布,新状态永远发现不了;大表只看最老的前 10 万行;没有最新业务日期、按日
行数、总行数的历史。剖析引擎的四类观察检查(data/catalog_profile_observe)把每次看到的记进 table_observations,这里拿这一次
和**同一口径**(basis_key 相同)的历史比,得出取值变化(写进 source_changes)。口径不同不比、照实说(comparable)。
规则(阈值第一版,要用真实库跑一段时间再调):
- **新码值**(codes_new):码值表标了「已列全」的,出现任何一个目录里没有的取值都算——数据证明它不再列全,这是机器唯一能
写进目录的事(撤销「已列全」,revoke_complete),严重程度是「要处理」。没列全的,新值要占非空行的至少 0.5%,而且要有同
口径的历史(第一次只建基线),已见过的(目录里的、历史里出现过的)不算新。
- **比例剧变**(codes_shift):和上一次同口径的观察算 PSI,两次都至少 1,000 行、都没被截断;PSI ≥ 0.25 记 warning,0.1–0.25
记 info,列出变化最大的几个取值。
- **断档**(date_stalled):最新业务日期没前进,且这个日期离今天超过 max(2 × 以前前进的中位间隔, 3 天);要见过至少一次
前进才知道它多久前进一次。
- **缺天**(date_gap):窗口里(有数据的第一天到最后一天之间)有日子没数据,而以前同口径的窗口都没有过。
- **量跌**(volume_drop):最近一个整天的行数不到它前面 28 天中位数的 30%(前 28 天至少 14 天有数据、中位数至少 20 行)。
最后一天和观察当天同一天(还在写入)时往前退一天。只看这一次的窗口,不需要历史。
- **行数**(rows_drop / rows_jump):比上一次同口径的少了 30% 以上 / 涨到 3 倍以上;依据是数据库统计的估算值时,要连续
两次都这样(和跌、涨之前那一次比)才报。数到上限、只知道「至少多少行」的不比。
"""
from __future__ import annotations
import math
import statistics
from collections.abc import Iterable, Mapping
from dataclasses import dataclass, field
from datetime import date, datetime, timedelta
from typing import Any
NEW_CODE_SHARE = 0.005
PSI_MIN_ROWS = 1000
PSI_WARN = 0.25
PSI_INFO = 0.1
PSI_EPS = 1e-4
PSI_TOP = 5
STALL_MIN_DAYS = 3
VOLUME_RATIO = 0.3
VOLUME_BASELINE_DAYS = 28
VOLUME_MIN_DAYS = 14
VOLUME_MIN_MEDIAN = 20
ROWS_DROP = 0.3
ROWS_JUMP = 3.0
GAPS_SHOWN = 10
@dataclass(frozen=True)
class Obs:
"""一次观察:kind、口径(basis_key)、观察到的(data)、什么时候(at)。"""
kind: str
basis_key: str
data: Mapping[str, Any]
at: datetime | None = None
@dataclass(frozen=True)
class Finding:
"""比出来的一条取值变化。key 是去重指纹的一部分;revoke_complete:要撤销码值项的「已列全」。"""
kind: str
severity: str
before: Any = None
after: Any = None
key: Any = None
revoke_complete: bool = False
extra: dict[str, Any] = field(default_factory=dict)
def comparable(current: Obs, history: Iterable[Obs]) -> tuple[list[Obs], str | None]:
"""同口径的历史(新的在前,照传进来的顺序)和一句说明:有历史、但口径都不同时说「口径不同,不比」;没有历史为 None。"""
items = list(history)
same = [h for h in items if h.basis_key == current.basis_key]
if items and not same:
return [], f"以前的观察口径不同({items[0].basis_key} → {current.basis_key}),这一次只建新口径的基线、不比"
return same, None
def _counts(data: Mapping[str, Any]) -> dict[str, int]:
out: dict[str, int] = {}
for pair in data.get("values") or []:
if isinstance(pair, (list, tuple)) and len(pair) == 2 and isinstance(pair[1], (int, float)):
out[str(pair[0])] = out.get(str(pair[0]), 0) + int(pair[1])
return out
def _share(n: int, total: int) -> float:
return round(n / total, 4) if total else 0.0
def psi(before: Mapping[str, int], after: Mapping[str, int]) -> float:
"""两个分布的 PSI(群体稳定性指数):Σ (q − p) · ln(q / p),p、q 是各取值占比,没出现的取值按 PSI_EPS 算。"""
total_b, total_a = sum(before.values()), sum(after.values())
if not total_b or not total_a:
return 0.0
value = 0.0
for code in set(before) | set(after):
p = max(before.get(code, 0) / total_b, PSI_EPS)
q = max(after.get(code, 0) / total_a, PSI_EPS)
value += (q - p) * math.log(q / p)
return value
def codes(current: Obs, history: Iterable[Obs], *, known: Iterable[str] = (), complete: bool = False) -> list[Finding]:
"""码值列的这一次和同口径历史比:新码值、比例剧变。known 是目录里码值项列出的取值;complete 是它标了「已列全」。"""
same, _note = comparable(current, history)
now = _counts(current.data)
rows = int(current.data.get("rows") or sum(now.values()))
listed = {str(k) for k in known}
out: list[Finding] = []
if complete:
fresh = [c for c in now if c not in listed]
if fresh:
out.append(Finding("codes_new", "action", before={"known": sorted(listed), "complete": True},
after={"codes": fresh, "shares": {c: _share(now[c], rows) for c in fresh}},
key=tuple(sorted(fresh)), revoke_complete=True))
elif same and not current.data.get("truncated") and not same[0].data.get("truncated"):
seen = listed | {c for h in same for c in _counts(h.data)}
fresh = [c for c, n in now.items() if c not in seen and rows and n / rows >= NEW_CODE_SHARE]
if fresh:
out.append(Finding("codes_new", "warning", before={"known": sorted(seen)},
after={"codes": fresh, "shares": {c: _share(now[c], rows) for c in fresh}},
key=tuple(sorted(fresh))))
if same:
prev = same[0]
then = _counts(prev.data)
prev_rows = int(prev.data.get("rows") or sum(then.values()))
if (rows >= PSI_MIN_ROWS and prev_rows >= PSI_MIN_ROWS and not current.data.get("truncated")
and not prev.data.get("truncated")):
value = psi(then, now)
if value >= PSI_INFO:
top = sorted(set(then) | set(now),
key=lambda c: -abs(_share(now.get(c, 0), rows) - _share(then.get(c, 0), prev_rows)))
moved = [[c, _share(then.get(c, 0), prev_rows), _share(now.get(c, 0), rows)] for c in top[:PSI_TOP]]
out.append(Finding("codes_shift", "warning" if value >= PSI_WARN else "info",
before={"rows": prev_rows}, after={"psi": round(value, 4), "rows": rows, "top": moved},
key=(current.basis_key, current.at.isoformat() if current.at else None)))
return out
def _day(value: Any) -> date | None:
try:
return date.fromisoformat(str(value)[:10])
except ValueError:
return None
def freshness(current: Obs, history: Iterable[Obs], *, today: date) -> list[Finding]:
"""最新业务日期没前进、而且离今天太久(断档)。"""
same, _note = comparable(current, history)
now = _day(current.data.get("max"))
if now is None or not same:
return []
previous = [d for d in (_day(h.data.get("max")) for h in same) if d is not None]
if not previous or now > max(previous):
return []
distinct = sorted(set(previous) | {now})
gaps = [(b - a).days for a, b in zip(distinct, distinct[1:])]
if not gaps:
return []
median = statistics.median(gaps)
threshold = max(2 * median, STALL_MIN_DAYS)
age = (today - now).days
if age <= threshold:
return []
return [Finding("date_stalled", "warning", before={"max": max(previous).isoformat(), "median_days": median},
after={"max": now.isoformat(), "age_days": age, "threshold_days": threshold},
key=now.isoformat())]
def _series(data: Mapping[str, Any]) -> dict[date, int]:
out: dict[date, int] = {}
for raw, n in (data.get("days") or {}).items():
d = _day(raw)
if d is not None and isinstance(n, (int, float)):
out[d] = out.get(d, 0) + int(n)
return out
def _holes(days: Mapping[date, int]) -> list[date]:
if not days:
return []
first, last = min(days), max(days)
return [first + timedelta(days=i) for i in range((last - first).days + 1)
if days.get(first + timedelta(days=i), 0) == 0]
def daily(current: Obs, history: Iterable[Obs], *, today: date) -> list[Finding]:
"""按日行数:缺天(要和同口径的历史比)、量跌(只看这一次的窗口)。"""
same, _note = comparable(current, history)
days = _series(current.data)
out: list[Finding] = []
holes = _holes(days)
if holes and same and all(not _holes(_series(h.data)) for h in same):
out.append(Finding("date_gap", "warning", before={"gaps": 0},
after={"gaps": len(holes), "days": [d.isoformat() for d in holes[:GAPS_SHOWN]]},
key=tuple(d.isoformat() for d in holes)))
if days:
last = max(days)
if last >= today:
last -= timedelta(days=1)
base = [days.get(last - timedelta(days=i), 0) for i in range(1, VOLUME_BASELINE_DAYS + 1)]
present = sum(1 for n in base if n > 0)
median = statistics.median(base) if base else 0
value = days.get(last, 0)
if present >= VOLUME_MIN_DAYS and median >= VOLUME_MIN_MEDIAN and value < VOLUME_RATIO * median:
out.append(Finding("volume_drop", "warning", before={"median": median, "days": VOLUME_BASELINE_DAYS},
after={"day": last.isoformat(), "rows": value, "ratio": round(value / median, 4)},
key=last.isoformat()))
return out
def _exact(data: Mapping[str, Any]) -> int | None:
rows = data.get("rows")
return int(rows) if isinstance(rows, int) and not isinstance(rows, bool) and rows >= 0 else None
def rows(current: Obs, history: Iterable[Obs]) -> list[Finding]:
"""行数:跌 30% 以上、涨到 3 倍以上。统计估算的要连续两次(都和跌、涨之前那一次比)。"""
same, _note = comparable(current, history)
now = _exact(current.data)
if now is None or not same:
return []
estimated = current.data.get("method") == "stats"
if estimated:
if len(same) < 2:
return []
prev, ref = _exact(same[0].data), _exact(same[1].data)
if prev is None or ref is None or not ref:
return []
dropped = now <= (1 - ROWS_DROP) * ref and prev <= (1 - ROWS_DROP) * ref
jumped = now >= ROWS_JUMP * ref and prev >= ROWS_JUMP * ref
else:
ref = _exact(same[0].data)
if ref is None or not ref:
return []
dropped = now <= (1 - ROWS_DROP) * ref
jumped = now >= ROWS_JUMP * ref
if dropped:
return [Finding("rows_drop", "warning", before={"rows": ref}, after={"rows": now, "estimated": estimated},
key=(current.basis_key, ref))]
if jumped:
return [Finding("rows_jump", "info", before={"rows": ref}, after={"rows": now, "estimated": estimated},
key=(current.basis_key, ref))]
return []
def compare(current: Obs, history: Iterable[Obs], *, today: date, known: Iterable[str] = (),
complete: bool = False) -> list[Finding]:
"""按种类分派。"""
if current.kind == "codes_observe":
return codes(current, history, known=known, complete=complete)
if current.kind == "freshness":
return freshness(current, history, today=today)
if current.kind == "daily_volume":
return daily(current, history, today=today)
if current.kind == "rows":
return rows(current, history)
return []
__all__ = ["Finding", "NEW_CODE_SHARE", "Obs", "PSI_INFO", "PSI_MIN_ROWS", "PSI_WARN", "codes", "comparable",
"compare", "daily", "freshness", "psi", "rows"]