ruoxi_sun 09b2add673 fingerprint: integrate fp_fusion model fingerprint benchmark
New evalharness/fingerprint/ package (from evalstone fp_fusion v1.1,
2026-09-07 pruning final): probe battery -> concurrent collection ->
five scoring views (verify/attribution/variant/adversarial/robustness),
bundled family aliases + 27 reference fingerprints (12 fp_fusion schema).

- CLI: 'evalharness fingerprint run ...' (REMAINDER passthrough, single
  source of arg definitions) + 'fingerprint list' for bundled references
- imports rewritten package-relative; direct 'python3 run_fp_fusion.py'
  execution kept working via package bootstrap
- offline analysis/collection scripts made path-independent (previously
  pinned to a /opt/evalscope path absent on this host)
- shell scripts: hardcoded API key -> FP_API_KEY/OPENAI_API_KEY env vars
- --reference accepts short names resolved against bundled references/
- pyproject: +httpx dependency, package-data references/*.json
- tests/test_fingerprint.py: 10 offline tests (battery definitions,
  assembly counts, normalization, signals, verdict ladder, CLI wiring)
- README: fingerprint section + architecture entry

Verified on H20-1: tests 10/10, installed CLI OK, full-protocol run vs
vectron GLM-5.3 reproduces baseline (score 0.9451, s_idn 0.846).
2026-09-11 03:52:21 +00:00

426 lines
20 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.

#!/usr/bin/env python3
"""FP-Fusion scorer: 自称提取 / 信号得分 / 门控 / 五档裁决 / 红旗."""
import json
import os
import re
from pathlib import Path
_SEVERITY_ORDER = {'HIGH': 0, 'MEDIUM': 1, 'LOW': 2}
_CUTOFF_RE = re.compile(
r"(?:cutoff|knowledge|training|截止|知识)[\s\w]*(?:is|was|in|until|up to|是|在)?\s*"
r"((?:january|february|march|april|may|june|july|august|september|october|november|"
r"december)\s+\d{4}|\d{4}[-/年]\d{1,2}|\d{4} 年? \d{1,2} 月|\d{4}年)",
re.IGNORECASE)
_NEGATION_RE = re.compile(
r"(not|isn't|isnt|am not|aren't|rather than|instead of|并非|不是|而不是|而不是)\s*"
r"(?:an?\s+)?\w{0,12}$", re.IGNORECASE)
_METACOG_NUM_RE = re.compile(
r"\b(\d{1,4}(?:\.\d+)?)\s*([bmb]ill?ion|b\b|mb|m\b|亿|万亿|千亿|百亿|十亿)\b|"
r"(\d+)\s*(?:gpus?|h800|a100|h100|v100|tpu|张\s?(?:gpu|卡))", re.IGNORECASE)
def load_aliases(path=None):
path = path or str(Path(__file__).parent / 'family_aliases.json')
with open(path, encoding='utf-8') as f:
return json.load(f)
def _families_in_text(text, aliases):
"""返回文本中提到的家族集合(带否定前缀过滤)."""
low = text.lower()
found = set()
for family, spec in aliases.items():
for tok in spec['tokens']:
idx = low.find(tok.lower())
while idx != -1:
prefix = low[max(0, idx - 25):idx]
if not _NEGATION_RE.search(prefix):
found.add(family)
break
idx = low.find(tok.lower(), idx + 1)
return found
def requested_family(model_name, aliases):
fams = _families_in_text(model_name.lower(), aliases)
return next(iter(fams)) if len(fams) == 1 else None
def identity_signal(i_records, aliases, req_family):
"""自称一致分 + 中英一致性 + 离群自称."""
parseable = consistent = 0
outliers, zh_en_bad = [], False
pair_fams = {}
for r in i_records:
text = r.get('response') or ''
if r.get('error') or not text:
continue
fams = _families_in_text(text, aliases)
meta = r.get('meta') or {}
pair = meta.get('pair')
lang = meta.get('lang')
if pair and lang in ('en', 'zh'):
pair_fams.setdefault(pair, {})[lang] = fams
if len(fams) == 1:
parseable += 1
fam = next(iter(fams))
if req_family is None or fam == req_family:
consistent += 1
else:
outliers.append({'probe': r['id'], 'claimed_family': fam,
'excerpt': text[:120]})
elif len(fams) > 1:
parseable += 1
if req_family and req_family not in fams:
outliers.append({'probe': r['id'], 'claimed_family': sorted(fams),
'excerpt': text[:120]})
# 中英配对一致性: 同一 pair 两侧声称家族交集非空 → 一致
for pair, sides in pair_fams.items():
en, zh = sides.get('en') or set(), sides.get('zh') or set()
if en and zh and not (en & zh):
zh_en_bad = True
s_idn = (consistent / parseable) if parseable else 0.5
return {'s_idn': s_idn, 'parseable': parseable, 'consistent': consistent,
'outliers': outliers[:5], 'zh_en_consistent': not zh_en_bad}
def meta_signal(k_records, i_records):
"""S_meta: 截止口径唯一 + 元认知审计不编造."""
cutoffs = set()
for r in k_records + i_records:
text = r.get('response')
if text:
for m in _CUTOFF_RE.finditer(text):
cutoffs.add(re.sub(r'\s+', ' ', m.group(1).strip().lower()))
metacog_fabricated = []
for r in k_records:
if not (r.get('meta') or {}).get('metacog'):
continue
text = r.get('response') or ''
if text and _METACOG_NUM_RE.search(text) and 'refuse' not in text.lower() \
and '无法' not in text and "don't have" not in text.lower():
metacog_fabricated.append({'probe': r['id'], 'excerpt': text[:120]})
cutoff_ok = 1.0 if len(cutoffs) <= 1 else 0.4
metacog_ok = 1.0 if not metacog_fabricated else 0.4
s_meta = (cutoff_ok + metacog_ok) / 2
return {'s_meta': s_meta, 'cutoffs_unique': sorted(cutoffs),
'metacog_fabricated': metacog_fabricated}
def dist_signal(entries, mean_jsd, split_half):
"""S_dist: 相对比 R = meanJSD / max(splitHalf, 0.02), 带绝对标尺封顶."""
if mean_jsd is None:
return None
sh = split_half if split_half and split_half > 0 else 0.02
ratio = mean_jsd / max(sh, 0.02)
if ratio < 2:
s = 1.0
elif ratio > 8:
s = 0.0
else:
s = 1.0 - (ratio - 2) / 6
if mean_jsd > 0.35: # 论文绝对标尺: 不同模型档封顶
s = min(s, 0.2)
return {'s_dist': s, 'mean_jsd': mean_jsd, 'split_half': split_half,
'relative_ratio': round(ratio, 2), 'comparable_cells': len(entries)}
def refuse_gradient_pattern(c_records):
"""拒答梯度向量(存档信号): {level: True(拒)/False(顺)}"""
grad = {}
for r in c_records:
g = (r.get('meta') or {}).get('refusal_grad')
if g:
text = (r.get('response') or '').lower()
grad[f'L{g}'] = any(s in text for s in
("i can't", "i cannot", "无法", "抱歉", "sorry"))
return grad
def length_compliance(s_records):
"""长度服从性(存档信号): 恰好 N 词/1 句 的服从率."""
out = []
for r in s_records:
n = (r.get('meta') or {}).get('len_ctrl')
if not n or r.get('error'):
continue
text = (r.get('response') or '').strip()
if n == 3:
words = len([w for w in re.split(r'\W+', text) if w])
out.append({'probe': r['id'], 'target': 3, 'actual_words': words,
'ok': words == 3})
else:
sents = len([x for x in re.split(r'[.!?。!?]', text) if x.strip()])
out.append({'probe': r['id'], 'target': 1, 'actual_sents': sents,
'ok': sents == 1})
return out
def verdict_from_score(score, has_reference):
if score >= 0.85:
v = 'VERIFIED'
elif score >= 0.70:
v = 'LIKELY_MATCH'
elif score >= 0.50:
v = 'INCONCLUSIVE'
elif score >= 0.30:
v = 'SUSPECTED_MISMATCH'
else:
v = 'MISMATCH'
if not has_reference and v == 'VERIFIED':
v = 'LIKELY_MATCH' # 无参考不得"验明正身"
return v
def build_report(records, d_norm, dist_cmp, model_name, reference_info,
aliases, tokens_used, elapsed_s,
attribution=None, adversarial=None, mode='verify'):
text_records = [r for r in records if r['layer'] in ('I', 'K', 'C', 'S')]
ok_text = [r for r in text_records if not r['error']]
total = len(records)
success = sum(1 for r in records if not r['error'])
rate = success / max(total, 1)
quality = ('SUFFICIENT' if success >= 8 and rate >= 0.8
else ('DEGRADED' if success >= 4 and rate >= 0.5 else 'INSUFFICIENT'))
req_family = requested_family(model_name, aliases)
i_records = [r for r in records if r['layer'] == 'I']
k_records = [r for r in records if r['layer'] == 'K']
c_records = [r for r in records if r['layer'] == 'C']
s_records = [r for r in records if r['layer'] == 'S']
idn = identity_signal(i_records, aliases, req_family)
meta = meta_signal(k_records, i_records)
# 延迟: 按输出 token 归一化的"固定开销"估算, 替代固定 10s 绝对阈值.
# decode_rate = median(text 单条延迟/completion_tokens) → 纯解码速度
# overhead = baseline_p50 decode_rate×基线平均completion_tokens
# 代理/中转会给每个请求叠加固定的网络开销, 短请求(基线)上最显形;
# 纯硬件慢(CPU)只影响 decode_rate, 不会产生 overhead → 不再冤枉慢端点。
text_ok = [r for r in ok_text if (r.get('completion_tokens') or 0) > 0]
per_tok = sorted(r['latency_ms'] / r['completion_tokens'] for r in text_ok)
decode_rate = per_tok[len(per_tok) // 2] if per_tok else None
base_records = [r for r in records if r['layer'] == 'BASE' and not r['error']]
base_ct = [r.get('completion_tokens') or 1 for r in base_records]
mean_base_ct = sum(base_ct) / len(base_ct) if base_ct else 1.0
baseline = dist_cmp.get('baseline_p50') if isinstance(dist_cmp, dict) else None
overhead_ms, overhead_ratio = None, None
if baseline and decode_rate:
overhead_ms = baseline - decode_rate * mean_base_ct
# 用比值而非绝对值判定: CPU 等慢端点的 prefill 开销会随硬件慢等比放大,
# 固定 10s 阈值会冤枉它; 真正的代理/中转会让短请求比按解码率外推贵数倍
expected = decode_rate * mean_base_ct
overhead_ratio = baseline / expected if expected > 0 else None
latency_anomaly = bool(overhead_ratio is not None and overhead_ratio > 5
and overhead_ms is not None and overhead_ms > 5_000)
red_flags = []
if quality != 'SUFFICIENT':
red_flags.append({'severity': 'HIGH' if quality == 'INSUFFICIENT' else 'MEDIUM',
'category': 'evidence',
'description': f'{quality} evidence: {success}/{total} probes succeeded',
'evidence': f'Success rate {rate:.0%}'})
if idn['parseable'] and idn['consistent'] < idn['parseable']:
red_flags.append({'severity': 'HIGH', 'category': 'identity',
'description': f"Self-identification deviates from requested "
f"name '{model_name}' (family={req_family})",
'evidence': json.dumps(idn['outliers'][:3], ensure_ascii=False)})
if not idn['zh_en_consistent']:
red_flags.append({'severity': 'MEDIUM', 'category': 'consistency_zh_en',
'description': 'Chinese vs English self-identification disagree',
'evidence': 'paired identity probes'})
if len(meta['cutoffs_unique']) > 1:
red_flags.append({'severity': 'HIGH', 'category': 'consistency',
'description': 'Inconsistent knowledge cutoff dates',
'evidence': ', '.join(meta['cutoffs_unique'])})
if meta['metacog_fabricated']:
red_flags.append({'severity': 'LOW', 'category': 'metacog',
'description': 'States specific parameter counts / training '
'hardware (typical of substituted small models)',
'evidence': json.dumps(meta['metacog_fabricated'][:2],
ensure_ascii=False)})
if latency_anomaly:
red_flags.append({'severity': 'MEDIUM', 'category': 'latency',
'description': f'Estimated fixed per-request overhead '
f'{overhead_ms:.0f}ms (baseline p50 {baseline:.0f}ms '
f'vs decode-rate expectation) suggests proxy/relay',
'evidence': f'decode_rate={decode_rate:.1f}ms/tok, '
f'base_ct={mean_base_ct:.1f}'})
# v1.1: 单 cell 极端分化 → 实锤级信号(兄弟假冒案例中均值被数字cell稀释, 单cell达1.0)
outlier_cells = dist_cmp.get('outlier_cells') or []
dist_outlier = bool(dist_cmp.get('dist_outlier'))
if dist_outlier:
red_flags.append({'severity': 'MEDIUM', 'category': 'dist_outlier',
'description': f'{len(outlier_cells)} cell(s) show extreme '
f'distribution divergence (JSD>0.5, n>=15)',
'evidence': json.dumps(outlier_cells, ensure_ascii=False)})
# ---- 融合 ----
has_ref = dist_cmp.get('mean_jsd') is not None
s_fam = (attribution or {}).get('s_fam', 0.0)
# 维度A有参考时引入家族归因信号0.25权重),身份/分布相应下调;
# 无参考(自证模式)保持原权重,归因仅作辅助展示。
if has_ref:
final = (0.35 * dist_cmp['s_dist'] + 0.20 * idn['s_idn']
+ 0.20 * meta['s_meta'] + 0.25 * s_fam)
else:
final = 0.60 * idn['s_idn'] + 0.40 * meta['s_meta']
if quality != 'SUFFICIENT':
final = min(final, 0.5)
score = round(max(0.0, min(1.0, final)), 4)
verdict = verdict_from_score(score, has_ref)
# v1.1: dist_outlier 实锤信号 → 裁决封顶 SUSPECTED_MISMATCH(不许高于此档;
# INCONCLUSIVE 也被视为"证据被稀释", 由离群 cell 证据直接升级)
if dist_outlier:
_order = ['VERIFIED', 'LIKELY_MATCH', 'INCONCLUSIVE', 'SUSPECTED_MISMATCH', 'MISMATCH']
if _order.index(verdict) < _order.index('SUSPECTED_MISMATCH'):
verdict = 'SUSPECTED_MISMATCH'
# 维度Cimpersonation 实锤 → 同样封顶 SUSPECTED_MISMATCH复用裁决帽机制
impersonation_flag = bool(adversarial and adversarial.get('impersonation_flag'))
if impersonation_flag:
_order = ['VERIFIED', 'LIKELY_MATCH', 'INCONCLUSIVE', 'SUSPECTED_MISMATCH', 'MISMATCH']
if _order.index(verdict) < _order.index('SUSPECTED_MISMATCH'):
verdict = 'SUSPECTED_MISMATCH'
report = {
'benchmark': 'fp_fusion',
'version': '1.1',
'score': score,
'num': total,
'verdict': verdict,
'mode': 'reference_verify' if has_ref else 'self_consistency',
'model': model_name,
'reference': reference_info,
'gate': {'total_probes': total, 'successful_probes': success,
'success_rate': round(rate, 3), 'quality': quality},
'signals': {
'dist': {k: v for k, v in (dist_cmp or {}).items() if k != 'baseline_p50'}
if has_ref else {'enabled': False,
'split_half_jsd': dist_cmp.get('split_half')},
'family': (attribution if attribution is not None
else {'enabled': False, 'note': 'reserved hook (v1 - 未启用归因)'}),
'identity': {'s_idn': round(idn['s_idn'], 3),
'parseable': idn['parseable'],
'consistent': idn['consistent'],
'zh_en_consistent': idn['zh_en_consistent'],
'outliers': idn['outliers']},
'meta': {'s_meta': round(meta['s_meta'], 3),
'cutoffs_unique': meta['cutoffs_unique'],
'refusal_gradient': refuse_gradient_pattern(c_records),
'length_compliance': length_compliance(s_records)},
'adversarial': adversarial if adversarial is not None else {'enabled': False},
'latency': {'baseline_p50_ms': baseline,
'decode_rate_ms_per_tok': round(decode_rate, 1) if decode_rate else None,
'estimated_overhead_ms': round(overhead_ms, 1) if overhead_ms is not None else None,
'overhead_ratio': round(overhead_ratio, 2) if overhead_ratio else None,
'anomaly': latency_anomaly},
},
'red_flags': sorted(red_flags, key=lambda f: _SEVERITY_ORDER.get(f['severity'], 3)),
'tokens_used': tokens_used,
'elapsed_s': round(elapsed_s, 1),
}
if mode != 'verify':
report['mode_detail'] = mode
return report
def robustness_signal(records, temperature_sweep=None):
"""维度 D鲁棒性正交信号。
三个轴:
temp_axis : 文本层同探针在不同温度下的回答一致性(归一化到 [0,1]1=完全一致)
lang_axis : 文本层 en/zh 同探针pair 配对)回答一致性率
paraphrase_axis : D 层同 cell 不同 prompt_var 的回答分布 JSD0=最稳1=最有漂移)
输出块(写入 report['signals']['robustness'])。
"""
text_ok = [r for r in records
if r.get('layer') in ('I', 'K', 'C', 'S', 'V') and not r.get('error')]
# ---- 温度轴:同一探针 id 在多个温度下的回答一致性 ----
temp_axis = {'enabled': bool(temperature_sweep and len(temperature_sweep) > 1),
'temperatures': temperature_sweep or [],
'probes_covered': 0, 'mean_consistency': None}
if temp_axis['enabled']:
by_probe = {}
for r in text_ok:
by_probe.setdefault(r['id'], []).append(r)
consist = []
for pid, rs in by_probe.items():
temps = {r.get('temperature') for r in rs}
if len(temps) < 2:
continue
temp_axis['probes_covered'] += 1
# 两两回答归一化比较
normed = [(r.get('response') or '').strip().lower() for r in rs]
same = 0
pairs = 0
for i in range(len(normed)):
for j in range(i + 1, len(normed)):
pairs += 1
if normed[i] and normed[i] == normed[j]:
same += 1
consist.append(same / pairs if pairs else 0)
if consist:
temp_axis['mean_consistency'] = round(sum(consist) / len(consist), 3)
# ---- 语言轴pair 配对的 en/zh 回答一致率 ----
lang_axis = {'enabled': False, 'probes_covered': 0, 'mean_consistency': None}
pair_probes = {}
for r in text_ok:
m = r.get('meta') or {}
if m.get('pair') and m.get('lang') in ('en', 'zh'):
pair_probes.setdefault(m['pair'], {})[m['lang']] = \
(r.get('response') or '').strip().lower()
if pair_probes:
lang_axis['enabled'] = True
matches = 0
checks = 0
for p, sides in pair_probes.items():
if sides.get('en') and sides.get('zh'):
checks += 1
if sides['en'] and sides['en'] == sides['zh']:
matches += 1
lang_axis['probes_covered'] = checks
lang_axis['mean_consistency'] = \
round(matches / checks, 3) if checks else None
# ---- 改写轴D 层同 cell 不同 prompt_var 的分布 JSD ----
para_axis = {'enabled': False, 'cells_covered': 0, 'mean_jsd': None}
d_ok = [r for r in records if r.get('layer') == 'D'
and not r.get('error') and r.get('prompt_var')]
if d_ok:
from .engine import jsd_bits
by_cell_var = {}
for r in d_ok:
cell = r['cell']
key = (cell, r.get('prompt_var'))
norm = r.get('norm') if 'norm' in r else None
by_cell_var.setdefault(cell, {})
cnt = by_cell_var[cell]
cnt_key = key
cnt.setdefault(cnt_key, {})
by_cell_var[cell][cnt_key] = cnt[cnt_key]
# 用 response first-token 简化做分布(避免引入 build_d_normalized 循环依赖)
tok = (r.get('response') or '').strip().lower().split()[0] \
if (r.get('response') or '').strip() else '__empty__'
cnt[cnt_key][tok] = cnt[cnt_key].get(tok, 0) + 1
jsds = []
cells_covered = 0
for cell, var_map in by_cell_var.items():
if len(var_map) < 2:
continue
cells_covered += 1
keys = list(var_map.keys())
for i in range(len(keys)):
for j in range(i + 1, len(keys)):
jsds.append(jsd_bits(var_map[keys[i]], var_map[keys[j]]))
if jsds:
para_axis['enabled'] = True
para_axis['cells_covered'] = cells_covered
para_axis['mean_jsd'] = round(sum(jsds) / len(jsds), 3)
return {'temp_axis': temp_axis, 'lang_axis': lang_axis,
'paraphrase_axis': para_axis}