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

384 lines
17 KiB
Python
Raw Permalink 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 engine: 双池并行调度 + 归一化 + JSD/split-half 统计.
并行结构:
pool D : D 层 420 条单 token 采样 (semaphore=d_concurrency, temperature=1.0)
pool TXT : 基线 T₀ (20 条极短请求) → I/K/C/S 文本层 36 条 (semaphore=text_concurrency,
max_tokens=TEXT_MAX_TOKENS 截断)
两池互不依赖, asyncio.gather 同时跑。
"""
import asyncio
import json
import math
import random
import re
import time
import httpx
from .battery import (ALL_CELL_DEFS, BASELINE_MAX_TOKENS, BASELINE_PROMPT,
BASELINE_SAMPLES, D_SAMPLES_PER_CELL, D_TEMPERATURE,
REFUSAL_STARTERS, TEXT_MAX_TOKENS, TEXT_TEMPERATURE)
# ---------------------------------------------------------------- 归一化 ----
_CN_DIGITS = {'': 0, '': 1, '': 2, '': 2, '': 3, '': 4, '': 5,
'': 6, '': 7, '': 8, '': 9, '': 10}
_COLOR_EN = {'red': 'red', 'blue': 'blue', 'green': 'green', 'yellow': 'yellow',
'black': 'black', 'white': 'white', 'purple': 'purple', 'violet': 'purple',
'orange': 'orange', 'pink': 'pink', 'brown': 'brown', 'gray': 'gray',
'grey': 'gray'}
_COIN_MAP = {'heads': 'heads', 'tails': 'tails', '正面': 'heads', '反面': 'tails',
'head': 'heads', 'tail': 'tails'}
_WEEKDAY_MAP = {'monday': 'monday', 'tuesday': 'tuesday', 'wednesday': 'wednesday',
'thursday': 'thursday', 'friday': 'friday', 'saturday': 'saturday',
'sunday': 'sunday', '周一': 'monday', '星期一': 'monday', '礼拜一': 'monday',
'周二': 'tuesday', '星期二': 'tuesday', '礼拜二': 'tuesday',
'周三': 'wednesday', '星期三': 'wednesday', '礼拜三': 'wednesday',
'周四': 'thursday', '星期四': 'thursday', '礼拜四': 'thursday',
'周五': 'friday', '星期五': 'friday', '礼拜五': 'friday',
'周六': 'saturday', '星期六': 'saturday', '礼拜六': 'saturday',
'周日': 'sunday', '星期日': 'sunday', '星期天': 'sunday',
'礼拜日': 'sunday', '礼拜天': 'sunday', '周末': 'sunday'}
_PUNCT_RE = re.compile(r'[\W_]+', re.UNICODE)
def _first_token(text):
return text.split()[0] if text.split() else ''
def normalize_answer(raw, domain):
"""移植 detector normalizer 的主干规则, 返回 (canonical, category)."""
if raw is None:
return None, 'error'
text = raw.strip()
if not text:
return None, 'empty'
low = text.lower()
if any(s in low for s in REFUSAL_STARTERS):
return None, 'refusal'
# NFC + 去标点/emoji + 全角数字转半角
cleaned = re.sub(r'[\uFF01-\uFF5E]',
lambda m: chr(ord(m.group(0)) - 0xFEE0), text)
cleaned = _PUNCT_RE.sub(' ', cleaned).strip().lower()
if not cleaned:
return None, 'empty'
tok = _first_token(cleaned)
kind = domain[0]
if kind == 'int':
digits = ''.join(ch if ch.isdigit() else str(_CN_DIGITS.get(ch, '')) for ch in tok)
digits = re.sub(r'\s+', '', digits)
if digits.isdigit():
v = int(digits)
if domain[1] <= v <= domain[2]:
return str(v), 'valid'
return tok, 'invalid'
if kind == 'letter':
if len(tok) == 1 and tok.isalpha():
return tok, 'valid'
m = re.fullmatch(r'[a-z]', tok) or re.match(r'^([a-z])', cleaned)
if m:
return m.group(1), 'valid'
return tok, 'invalid'
if kind == 'color':
# 对齐 detector 参考约定: en 归一为英文标准色; zh 保留中文、去掉尾部'色'
# (参考库实证: random-color:zh keys = {"蓝","蓝紫"}, en = {"blue",...})
tok = _first_token(cleaned)
if not tok:
return None, 'empty'
if all(ord(ch) < 128 for ch in tok):
return _COLOR_EN.get(tok, tok), 'valid'
if len(tok) > 1 and tok.endswith(''):
tok = tok[:-1]
return tok, 'valid'
if kind == 'coin':
for k, v in _COIN_MAP.items():
if k in cleaned:
return v, 'valid'
return tok, 'invalid'
if kind == 'enum':
for v in domain[1]:
if v in cleaned:
return v, 'valid'
for k, v in _WEEKDAY_MAP.items():
if k in cleaned:
return v, 'valid'
return tok, 'invalid'
return tok, 'valid' # word 域: 任意词有效
# ---------------------------------------------------------------- 统计 ----
def jsd_bits(counts_p, counts_q):
"""Jensen-Shannon divergence, base 2, 范围 [0,1]."""
tp, tq = sum(counts_p.values()), sum(counts_q.values())
if tp <= 0 or tq <= 0:
return 0.0
support = set(counts_p) | set(counts_q)
hm = hp = hq = 0.0
for k in support:
p = counts_p.get(k, 0) / tp
q = counts_q.get(k, 0) / tq
m = (p + q) / 2
if m > 0:
hm -= m * math.log2(m)
if p > 0:
hp -= p * math.log2(p)
if q > 0:
hq -= q * math.log2(q)
return min(1.0, max(0.0, hm - (hp + hq) / 2))
def distributions_by_cell(samples):
"""samples: [{'cell':, 'norm':, 'cat':, 'arrival':}] → {cell: Counter}"""
out = {}
for s in samples:
if s['cat'] == 'valid' and s['norm'] is not None:
out.setdefault(s['cell'], {})
out[s['cell']][s['norm']] = out[s['cell']].get(s['norm'], 0) + 1
return out
def compare_cells(dist_a, dist_b, min_valid=10):
"""逐 cell JSD(双方 ≥min_valid 才可比), 返回按 JSD 降序的 entries + meanJsd."""
entries = []
for cell in sorted(set(dist_a) & set(dist_b)):
if sum(dist_a[cell].values()) < min_valid or sum(dist_b[cell].values()) < min_valid:
continue
entries.append({'cell': cell, 'jsd': jsd_bits(dist_a[cell], dist_b[cell]),
'valid_a': sum(dist_a[cell].values()),
'valid_b': sum(dist_b[cell].values())})
entries.sort(key=lambda e: e['jsd'], reverse=True)
mean = (sum(e['jsd'] for e in entries) / len(entries)) if entries else None
return entries, mean
def split_half_jsd(samples, min_per_half=5):
"""按到达顺序奇偶对半分, 逐 cell JSD 后取平均."""
halves = {}
for s in samples:
if s['cat'] != 'valid' or s['norm'] is None:
continue
key = (s['cell'], s['arrival'] % 2)
halves.setdefault(key, {})
halves[key][s['norm']] = halves[key].get(s['norm'], 0) + 1
jsds = []
for cell in {k[0] for k in halves}:
even, odd = halves.get((cell, 0)), halves.get((cell, 1))
if even and odd and sum(even.values()) >= min_per_half and sum(odd.values()) >= min_per_half:
jsds.append(jsd_bits(even, odd))
return (sum(jsds) / len(jsds)) if jsds else None
# ---------------------------------------------------------------- 引擎 ----
class FusionEngine:
def __init__(self, api_url, model, timeout=120, d_samples=D_SAMPLES_PER_CELL,
baseline_samples=BASELINE_SAMPLES, text_limit=0,
d_concurrency=4, text_concurrency=3,
text_max_tokens=TEXT_MAX_TOKENS, thinking=False,
extra_body=None, system_prompt_override=None, logprobs=False,
temperature_sweep=None, prompt_variants=0, d_cells=None,
api_key=None):
self.base = api_url.rstrip('/')
self.model = model
self.timeout = timeout
self.d_samples = d_samples
self.baseline_samples = baseline_samples
self.text_limit = text_limit # >0 时只跑前 N 条文本探针(冒烟用)
self.d_sem = asyncio.Semaphore(d_concurrency)
self.text_sem = asyncio.Semaphore(text_concurrency)
self.text_max_tokens = text_max_tokens
self.extra = {'chat_template_kwargs': {'thinking': thinking}}
# 维度A/C 扩展:额外请求体字段、全局 system prompt 覆盖(对抗伪装)、
# logprobs 采集(灰盒预留,一期只采集不评分)
self.extra_body = dict(extra_body or {})
self.system_prompt_override = system_prompt_override
self.logprobs = logprobs
# 维度D温度扫描 + D 层 paraphrase 变体(鲁棒性正交)
self.temperature_sweep = temperature_sweep or []
self.prompt_variants = max(0, int(prompt_variants or 0))
# 剪枝落地D cell 白名单None=全 26 cell保持旧行为
self.d_cells = set(d_cells) if d_cells else None
# API 鉴权vectron 等需 BearerNone=本地无鉴权端点,同旧行为)
self.api_key = (api_key or '').strip() or None
self.records = [] # 所有请求的原始记录
self.baseline_p50 = None
self.tokens_in = self.tokens_out = 0
# -- 单次请求 ---------------------------------------------------------
async def _chat(self, sem, user_prompt, max_tokens, temperature, system_prompt,
tag, rec):
# 对抗模式:全局覆盖 system prompt伪装角色否则用探针自带
sys_text = (self.system_prompt_override or '').strip() or system_prompt
body = {'model': self.model, 'temperature': temperature,
'max_tokens': max_tokens, 'stream': False,
'messages': [{'role': 'system', 'content': sys_text},
{'role': 'user', 'content': user_prompt}],
**self.extra, **self.extra_body}
if self.logprobs:
body['logprobs'] = True
body.setdefault('top_logprobs', 5)
headers = {'Content-Type': 'application/json'}
if self.api_key:
headers['Authorization'] = f'Bearer {self.api_key}'
t0 = time.perf_counter()
err = None
content, usage = None, None
top_logprobs = None
# 慢端点排队不可控: 所有请求统一放宽下限 300s(含 D 层单 token 请求)
eff_timeout = max(self.timeout, 300)
try:
async with sem:
async with httpx.AsyncClient(timeout=eff_timeout) as client:
r = await client.post(f'{self.base}/chat/completions',
json=body, headers=headers)
r.raise_for_status()
data = r.json()
content = data['choices'][0]['message'].get('content') or ''
usage = data.get('usage') or {}
# 灰盒预留:记录首个完成 token 的 top-logprobs供 B 期概率校准)
if self.logprobs:
lp = (data.get('choices') or [{}])[0].get('logprobs') or {}
tokens = lp.get('content') or []
top_logprobs = [
{'token': t.get('token'), 'logprob': t.get('logprob'),
'top': t.get('top_logprobs')}
for t in tokens[:16]
] if isinstance(tokens, list) else None
except Exception as e:
# httpx 超时异常的 str() 可能为空串 → 永远保证 error 非空(fail-closed 依赖它)
err = str(e) or type(e).__name__
latency = (time.perf_counter() - t0) * 1000
if usage:
self.tokens_in += usage.get('prompt_tokens') or 0
self.tokens_out += usage.get('completion_tokens') or 0
rec.update({'latency_ms': round(latency, 1), 'response': content,
'error': err, 'tag': tag,
'completion_tokens': (usage or {}).get('completion_tokens'),
'top_logprobs': top_logprobs})
self.records.append(rec)
return rec
SYS_ONE_WORD = {'en': 'Answer with exactly one word. No punctuation, no explanation.',
'zh': '只回答一个词,不要标点,不要解释。'}
SYS_DEFAULT = 'You are a helpful assistant.'
# -- 基线 --------------------------------------------------------------
async def _run_baseline(self):
sem = asyncio.Semaphore(3)
tasks = [self._chat(sem, BASELINE_PROMPT, BASELINE_MAX_TOKENS, 0.0,
self.SYS_DEFAULT, 'baseline',
{'id': f'baseline_{i}', 'layer': 'BASE'})
for i in range(self.baseline_samples)]
done = [r for r in await asyncio.gather(*tasks) if not r['error']]
lats = sorted(r['latency_ms'] for r in done)
self.baseline_p50 = lats[len(lats) // 2] if lats else None
# -- D 层 ---------------------------------------------------------------
def _d_jobs(self):
jobs = []
variants = self.prompt_variants if self.prompt_variants > 0 else None
for c in ALL_CELL_DEFS:
for lang in ('en', 'zh'):
cell_id = f"{c['id']}:{lang}"
if self.d_cells is not None and cell_id not in self.d_cells:
continue
pool = c['par'][lang]
for i in range(self.d_samples):
# 维度D 改写轴:轮换使用池中前 N 个 paraphrase否则随机
if variants:
prompt = pool[i % min(variants, len(pool))]
else:
prompt = random.choice(pool)
jobs.append((cell_id, c['domain'], prompt, lang, prompt))
random.shuffle(jobs) # 防单 cell 突发(触发缓存/限流偏差)
# 统一为 5 元组cell, domain, prompt, lang, prompt_var(原样副本)
return [(j[0], j[1], j[2], j[3], j[4]) for j in jobs]
async def _run_d_layer(self):
sem = self.d_sem
tasks = []
for idx, (cell_id, domain, prompt, lang, prompt_var) in enumerate(self._d_jobs()):
rec = {'id': f'd_{cell_id}_{idx}', 'layer': 'D', 'cell': cell_id,
'lang': lang, 'prompt': prompt, 'prompt_var': prompt_var,
'arrival': idx}
tasks.append(self._chat(sem, prompt, 16, D_TEMPERATURE,
self.SYS_ONE_WORD[lang], 'dist', rec))
await asyncio.gather(*tasks)
# -- 文本层 -------------------------------------------------------------
async def _run_text_layer(self, probes):
sem = self.text_sem
tasks = []
temps = self.temperature_sweep or [TEXT_TEMPERATURE]
for p in probes:
# 探针自带 temperature如 V 层确定性探针用 0.0)优先;
# 否则 sweep 多温度;再否则默认 TEXT_TEMPERATURE。
p_meta = p.get('meta') or {}
probe_temp = p_meta.get('temperature')
per_temps = [probe_temp] if probe_temp is not None else temps
for t in per_temps:
rec = {'id': p['id'], 'layer': p['layer'], 'prompt': p['text'],
'temperature': t,
'meta': {k: v for k, v in p.items()
if k in ('pair', 'lang', 'metacog', 'refusal_grad',
'len_ctrl', 'role', 'expect_family')}}
tasks.append(self._chat(sem, p['text'], self.text_max_tokens,
t, self.SYS_DEFAULT, 'text', rec))
await asyncio.gather(*tasks)
# -- 主入口 --------------------------------------------------------------
async def run(self, text_probes):
if self.text_limit > 0:
text_probes = text_probes[:self.text_limit]
# 基线必须独占测量: 若与 D 池并发, CPU 端点的基线会被排队延迟污染
await self._run_baseline()
await asyncio.gather(self._run_d_layer(), self._run_text_layer(text_probes))
return self.records
# -- 结果整理 -------------------------------------------------------------
def d_samples_normalized(self):
"""返回 [{cell, norm, cat, arrival}]"""
out = []
for r in self.records:
if r.get('tag', r.get('layer')) != 'dist':
continue
# _chat 不知 domain; 由调用方(cell)反查 —— 在 run_fp_fusion 里完成
out.append(r)
return out
def build_d_normalized(records):
"""records(D层) → 归一化样本列表"""
from .battery import ALL_CELL_DEFS
dom = {}
for c in ALL_CELL_DEFS:
dom[f'{c["id"]}:en'] = c['domain']
dom[f'{c["id"]}:zh'] = c['domain']
out = []
for r in records:
if r['layer'] != 'D':
continue
norm, cat = (None, 'error') if r['error'] else normalize_answer(
r.get('response'), dom[r['cell']])
out.append({'cell': r['cell'], 'norm': norm, 'cat': cat,
'arrival': r['arrival'], 'error': r['error']})
return out
def load_reference(path):
"""加载 detector schema 的单模型参考指纹 → {cellId: counts}"""
with open(path, encoding='utf-8') as f:
ref = json.load(f)
if ref.get('formatVersion') != 1 or not isinstance(ref.get('cells'), dict):
raise ValueError(f'unsupported reference format: {path}')
cells = {}
for cid, c in ref['cells'].items():
counts = c.get('counts', {})
cells[cid] = {str(k): v for k, v in counts.items()}
return {'model': ref.get('model'), 'cells': cells}