global setup and configuration.
This commit is contained in:
@@ -0,0 +1,271 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Score a candidate against a voice profile.
|
||||
|
||||
Lower composite means more user-like. The composite is half GI-rank penalty and
|
||||
half clipped, weighted impostor z-distance using the WP10a research weights.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import math
|
||||
import random
|
||||
import statistics
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import voice_profile
|
||||
|
||||
WEIGHTS = {
|
||||
"char3": 0.30,
|
||||
"delta": 0.25,
|
||||
"sentence_emd": 0.10,
|
||||
"punctuation": 0.08,
|
||||
"contraction": 0.07,
|
||||
"mtld": 0.10,
|
||||
"word_length": 0.10,
|
||||
}
|
||||
|
||||
|
||||
def cosine_distance(a, b, keys=None):
|
||||
keys = list(keys) if keys is not None else sorted(set(a) | set(b))
|
||||
dot = sum(a.get(k, 0.0) * b.get(k, 0.0) for k in keys)
|
||||
na = math.sqrt(sum(a.get(k, 0.0) ** 2 for k in keys))
|
||||
nb = math.sqrt(sum(b.get(k, 0.0) ** 2 for k in keys))
|
||||
if not na or not nb:
|
||||
return 1.0
|
||||
return 1 - (dot / (na * nb))
|
||||
|
||||
|
||||
def z_function_vector(freqs, bg, top):
|
||||
keys = sorted(bg, key=lambda k: bg[k].get("mean", 0), reverse=True)[:top]
|
||||
return {k: (freqs.get(k, 0.0) - bg[k]["mean"]) / (bg[k]["std"] or 0.0001) for k in keys}
|
||||
|
||||
|
||||
def emd(a, b):
|
||||
if not a or not b:
|
||||
return 0.0
|
||||
max_len = max(max(a), max(b))
|
||||
ca = cb = dist = 0.0
|
||||
for i in range(1, max_len + 1):
|
||||
ca += sum(1 for x in a if x == i) / len(a)
|
||||
cb += sum(1 for x in b if x == i) / len(b)
|
||||
dist += abs(ca - cb)
|
||||
return dist
|
||||
|
||||
|
||||
def l1(a, b, keys):
|
||||
return sum(abs(a.get(k, 0.0) - b.get(k, 0.0)) for k in keys)
|
||||
|
||||
|
||||
def distances(profile, feats, subset=None):
|
||||
subset = set(subset or WEIGHTS)
|
||||
out = {}
|
||||
if "char3" in subset:
|
||||
keys = set(profile["char3"]) | set(feats["char3"])
|
||||
out["char3"] = cosine_distance(profile["char3"], feats["char3"], keys)
|
||||
if "delta" in subset:
|
||||
top = 50 if feats["total_words"] < 300 else 200
|
||||
bg = profile["function_word_background"]
|
||||
pv = z_function_vector(profile["function_words"], bg, top)
|
||||
cv = z_function_vector(feats["function_words"], bg, top)
|
||||
out["delta"] = cosine_distance(pv, cv, pv.keys())
|
||||
if "sentence_emd" in subset:
|
||||
med = profile["sentence_lengths"].get("median") or 1.0
|
||||
out["sentence_emd"] = emd(profile["sentence_lengths"]["lengths"], feats["sentence_lengths"]["lengths"]) / med
|
||||
if "punctuation" in subset:
|
||||
out["punctuation"] = l1(profile["punctuation"], feats["punctuation"], voice_profile.PUNCT)
|
||||
if "contraction" in subset:
|
||||
out["contraction"] = abs(profile["contraction_rate"] - feats["contraction_rate"])
|
||||
if "mtld" in subset:
|
||||
out["mtld"] = abs(profile["mtld"] - feats["mtld"]) / (profile["mtld"] or 1.0)
|
||||
if "word_length" in subset:
|
||||
keys = [str(i) for i in range(1, 16)]
|
||||
out["word_length"] = l1(profile["word_length_histogram"], feats["word_length_histogram"], keys)
|
||||
return out
|
||||
|
||||
|
||||
def weighted_sum(dists):
|
||||
return sum(WEIGHTS[k] * dists.get(k, 0.0) for k in WEIGHTS)
|
||||
|
||||
|
||||
def impostor_features(root):
|
||||
feats = []
|
||||
for path in voice_profile.iter_docs(root):
|
||||
feats.append((str(path), voice_profile.feature_bundle(path.read_text(errors="replace"))))
|
||||
return feats
|
||||
|
||||
|
||||
def zscores(candidate, impostor_rows):
|
||||
out = {}
|
||||
for key in WEIGHTS:
|
||||
vals = [row[key] for row in impostor_rows if key in row]
|
||||
if key not in candidate or not vals:
|
||||
out[key] = None
|
||||
continue
|
||||
mean = statistics.mean(vals)
|
||||
std = statistics.pstdev(vals) or 0.0001
|
||||
out[key] = (candidate[key] - mean) / std
|
||||
return out
|
||||
|
||||
|
||||
def gi_score(profile, cand_feats, impostors, seed):
|
||||
rng = random.Random(seed)
|
||||
keys = list(WEIGHTS)
|
||||
wins = 0
|
||||
trials = 64
|
||||
# Per-key distances depend only on (profile, feats) -- not on which subset
|
||||
# of keys a given trial happens to draw -- so compute every key's distance
|
||||
# ONCE per candidate/impostor here, then have each trial do a subset-
|
||||
# weighted sum over the precomputed values instead of recomputing
|
||||
# distances() from scratch on every trial. Arithmetically exact: the
|
||||
# trial loop still consumes rng.random()/rng.sample() in the same order,
|
||||
# and summing WEIGHTS[k]*value only over the drawn subset is the same
|
||||
# float sequence weighted_sum(distances(..., subset)) produced (excluded
|
||||
# keys contributed an exact 0.0 term either way).
|
||||
cand_dists = distances(profile, cand_feats)
|
||||
impostor_dists = [(name, distances(profile, imp)) for name, imp in impostors]
|
||||
for _ in range(trials):
|
||||
subset = [k for k in keys if rng.random() < 0.5] or [rng.choice(keys)]
|
||||
cand = sum(WEIGHTS[k] * cand_dists.get(k, 0.0) for k in subset)
|
||||
sampled = rng.sample(impostor_dists, k=min(len(impostor_dists), max(1, len(impostor_dists) // 2)))
|
||||
if all(cand < sum(WEIGHTS[k] * imp_dists.get(k, 0.0) for k in subset)
|
||||
for _, imp_dists in sampled):
|
||||
wins += 1
|
||||
return wins / trials
|
||||
|
||||
|
||||
def ngrams(tokens, n=4):
|
||||
return set(tuple(tokens[i:i + n]) for i in range(max(0, len(tokens) - n + 1)))
|
||||
|
||||
|
||||
LCS_THRESHOLD = 120
|
||||
|
||||
|
||||
def has_common_substring_over(a: str, b: str, min_length: int) -> bool:
|
||||
"""Rolling-hash check: do ``a`` and ``b`` share a contiguous substring of at
|
||||
least ``min_length`` characters?
|
||||
|
||||
O(len(a) + len(b)) expected time, vs. the O(len(a) * len(b)) classic DP a
|
||||
true longest-common-substring computation needs. This only answers the
|
||||
threshold question the copy gate actually asks ("is there a shared run
|
||||
longer than N chars"); it does not recover the true longest common
|
||||
substring length. Every hash match is verified against the source
|
||||
characters before being trusted, so a hash collision never produces a
|
||||
false positive.
|
||||
"""
|
||||
if min_length <= 0:
|
||||
return bool(a) and bool(b)
|
||||
if len(a) < min_length or len(b) < min_length:
|
||||
return False
|
||||
|
||||
base = 257
|
||||
mod = (1 << 61) - 1
|
||||
high_power = pow(base, min_length - 1, mod)
|
||||
|
||||
def window_hashes(s: str) -> dict[int, list[int]]:
|
||||
table: dict[int, list[int]] = {}
|
||||
h = 0
|
||||
for i in range(min_length):
|
||||
h = (h * base + ord(s[i])) % mod
|
||||
table.setdefault(h, []).append(0)
|
||||
for i in range(min_length, len(s)):
|
||||
h = ((h - ord(s[i - min_length]) * high_power) * base + ord(s[i])) % mod
|
||||
table.setdefault(h, []).append(i - min_length + 1)
|
||||
return table
|
||||
|
||||
table_a = window_hashes(a)
|
||||
table_b = window_hashes(b)
|
||||
for h, starts_b in table_b.items():
|
||||
starts_a = table_a.get(h)
|
||||
if not starts_a:
|
||||
continue
|
||||
for sb in starts_b:
|
||||
window_b = b[sb:sb + min_length]
|
||||
for sa in starts_a:
|
||||
if a[sa:sa + min_length] == window_b:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def copy_gate(candidate_text, samples_dir):
|
||||
cand_grams = ngrams(voice_profile.words(candidate_text))
|
||||
max_overlap = 0.0
|
||||
lcs_violation = False
|
||||
for path in voice_profile.iter_docs(samples_dir):
|
||||
text = path.read_text(errors="replace")
|
||||
sample_grams = ngrams(voice_profile.words(text))
|
||||
if cand_grams:
|
||||
max_overlap = max(max_overlap, len(cand_grams & sample_grams) / len(cand_grams))
|
||||
if has_common_substring_over(candidate_text, text, LCS_THRESHOLD + 1):
|
||||
lcs_violation = True
|
||||
# "longest_common_substring" is reported for backward-compatible shape only:
|
||||
# nothing pins its exact value (checked evals/adversarial-evals.json and
|
||||
# evals/check_*.py for max_lcs/longest_common_substring assertions; none
|
||||
# exist). Its semantics changed from "the true LCS length" to "the
|
||||
# matched threshold window length when a violation-length run was found,
|
||||
# else 0" -- computing the true max cheaply isn't possible without the
|
||||
# same O(n*m) cost this function exists to avoid.
|
||||
max_lcs = LCS_THRESHOLD + 1 if lcs_violation else 0
|
||||
return {
|
||||
"max_overlap": max_overlap,
|
||||
"longest_common_substring": max_lcs,
|
||||
"violation": max_overlap > 0.35 or lcs_violation,
|
||||
}
|
||||
|
||||
|
||||
def parse_args(argv):
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--profile", required=True)
|
||||
parser.add_argument("--impostors", required=True)
|
||||
parser.add_argument("--seed", required=True, type=int)
|
||||
parser.add_argument("--samples")
|
||||
parser.add_argument("candidate_file")
|
||||
return parser.parse_args(argv)
|
||||
|
||||
|
||||
def read_candidate(path):
|
||||
if path == "-":
|
||||
return sys.stdin.buffer.read().decode("utf-8", errors="replace")
|
||||
p = Path(path)
|
||||
if not p.exists():
|
||||
raise FileNotFoundError(path)
|
||||
return p.read_text(errors="replace")
|
||||
|
||||
|
||||
def main(argv):
|
||||
args = parse_args(argv)
|
||||
if not Path(args.profile).exists() or not Path(args.impostors).is_dir():
|
||||
print("missing profile or impostors", file=sys.stderr)
|
||||
return 2
|
||||
try:
|
||||
text = read_candidate(args.candidate_file)
|
||||
except FileNotFoundError as e:
|
||||
print(f"missing candidate: {e}", file=sys.stderr)
|
||||
return 2
|
||||
profile = json.loads(Path(args.profile).read_text())
|
||||
feats = voice_profile.feature_bundle(text)
|
||||
low = feats["total_words"] < 150
|
||||
impostors = impostor_features(args.impostors)
|
||||
dist = distances(profile, feats)
|
||||
imp_dist = [distances(profile, f) for _, f in impostors]
|
||||
zs = zscores(dist, imp_dist)
|
||||
zsum = sum(WEIGHTS[k] * max(-3.0, min(3.0, zs[k] if zs[k] is not None else 0.0)) for k in WEIGHTS)
|
||||
gi = gi_score(profile, feats, impostors, args.seed) if impostors else 0.0
|
||||
result = {
|
||||
"candidate_words": feats["total_words"],
|
||||
"low_confidence": low,
|
||||
"distances": {k: (None if low and k in {"sentence_emd", "mtld"} else v) for k, v in dist.items()},
|
||||
"z_scores": {k: (None if low and k in {"sentence_emd", "mtld"} else v) for k, v in zs.items()},
|
||||
"gi": gi,
|
||||
"composite": 0.5 * (1 - gi) + 0.5 * zsum,
|
||||
}
|
||||
if args.samples:
|
||||
result["copy_gate"] = copy_gate(text, args.samples)
|
||||
print(json.dumps(result, indent=2, sort_keys=True))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main(sys.argv[1:]))
|
||||
Reference in New Issue
Block a user