18 KiB
Eval — Semantic Stability (Seed & Prompt Jitter, stdlib-only)
🧭 Quick Return to Map
You are in a sub-page of Eval.
To reorient, go back here:
- Eval — model evaluation and benchmarking
- WFGY Global Fix Map — main Emergency Room, 300+ structured fixes
- WFGY Problem Map 1.0 — 16 reproducible failure modes
Think of this page as a desk within a ward.
If you need the full triage and all prescriptions, return to the Emergency Room lobby.
Evaluation disclaimer (semantic stability)
Stability scores in this page are heuristic signals about how outputs move under small changes.
They do not prove global robustness or safety and should be combined with other checks.
Goal
Quantify how stable your pipeline is under small, non-semantic perturbations: different seeds, low temperature noise, and benign prompt jitters (punctuation/whitespace/synonym swaps). A robust system should keep claims, citations, refusals, and constraint echos invariant (or nearly so).
What you get
- Clear invariants: Claim, Citation, Refusal, Constraint Echo (SCU)
- Deterministic metrics: I-AA (intra-answer agreement), NED (normalized edit distance), CSS (citation-set stability), RCR (refusal consistency rate)
- A stdlib-only runner that repeats each QID under seeds & jitters, and a strict CI gate
1) Stability Invariants & Metrics
Let a question q be executed N times with seed set S and jitter set J. Each run r produces:
claim(string) or exact refusal tokennot in contextcitations(list of ids, scoped to retrieved ids)- Optional:
constraints_echo(list of locked constraints)
Normalization
canon(claim)= lowercase → strip punctuation/extra spaces.C(containment): at least onegold_claim_substr∈canon(claim)(≥5 chars, case-insensitive).H(citation hit):(citations ∩ gold_citations) ≠ ∅andcitations ⊆ retrieved_ids.
Per-QID metrics
- RCR (Refusal Consistency Rate) = max class frequency of
is_refusalover N runs. - ACR (All-run Containment Rate) = |{runs with C=true}| / N.
- CGHC (Citation Gold Hit Consistency) = |{runs with H=true}| / N.
- CSS (Citation Set Stability) =
|∩_runs citations| / |∪_runs citations|(IoU of citation sets). - NED₅₀ (Median Normalized Edit Distance) = median over all run pairs
ned(claim_i, claim_j)where [ \text{ned}(a,b) = \frac{\text{lev}(a,b)}{\max(|a|,|b|,1)} ] - SCU-Cons (Constraint Echo Consistency) = 1 if every run’s
constraints_echoequals the locked set; else 0.
We prefer semantic invariants (ACR, CGHC, CSS) over surface-similarity alone (NED), but we track both.
Default stability gates (suggested)
- For
answerable=trueitems:- ACR ≥ 0.95, CGHC ≥ 0.95, CSS ≥ 0.70, NED₅₀ ≤ 0.20
- If constraints provided: SCU-Cons = 1
- For
answerable=falseitems:- RCR ≥ 0.98 (refusal nearly always preserved)
2) Experiment Design (Seeds × Jitters)
Seeds: e.g., S = {0,1,2,3,4} with temperature ∈ {0.0, 0.2} (model-side)
Prompt jitters (question-side; deterministic & benign):
ws— normalize/densify whitespace (adds/removes single spaces)punct— swap?↔—, add commas where harmlesssyn— swap common verbs: explain→describe, list→enumerate, compare→contrastorder— reorder a pair of non-semantic clauses (“with citations, in one sentence”)
Each run picks (seed, jitter) from a small grid. No external libs; all jitters are pure string transforms.
3) Data Contracts (recap)
-
Gold (
ProblemMap/eval/gold.jsonl)
Same as other evals:qid,question,answerable,gold_claim_substr,gold_citations, optionalconstraints. -
Traces (per-run) appended to
runs/stability.jsonlby this runner:
{
"qid":"A0001","run_id":"A0001#seed=2;j=punct",
"seed":2,"jitter":"punct",
"answer_json":{"claim":"X rejects null keys.","citations":["p1#2"],"constraints_echo":["X rejects null keys."]},
"retrieved_ids":["p1#1","p1#2","p2#1"]
}
4) Reference Runner (stdlib-only)
Save as ProblemMap/eval/semantic_stability.py:
#!/usr/bin/env python3
import json, argparse, random, time, re, urllib.request, itertools, math, string, sys, os
REFUSAL = "not in context"
# --------- prompt jitters (benign, deterministic) ----------------------------
SYN_MAP = {
r"\bexplain\b": "describe",
r"\blist\b": "enumerate",
r"\bcompare\b": "contrast",
r"\bshow\b": "display",
}
def jitter_ws(q): # add/remove single spaces around commas/colons
q = re.sub(r"\s+,", ",", q)
q = re.sub(r",\s*", ", ", q)
q = re.sub(r"\s+:", ": ", q)
q = re.sub(r"\s{2,}", " ", q)
return q.strip()
def jitter_punct(q):
q = q.replace("?", " ?").replace(" ", " ")
q = q.replace("—", "-").replace("–", "-")
if not q.endswith("?") and q[-1] not in ".!?":
q += "?"
return q
def jitter_syn(q):
s = q
for pat, repl in SYN_MAP.items():
s = re.sub(pat, repl, s, flags=re.IGNORECASE)
return s
def jitter_order(q):
# swap order of two benign optional clauses if present
parts = re.split(r"\s+with\s+citations\s*,?\s*|\s*,?\s*in\s+one\s+sentence\s*", q, flags=re.IGNORECASE)
if len(parts) >= 2 and "with citations" in q.lower() and "in one sentence" in q.lower():
# rebuild in swapped order
base = parts[0].strip()
return f"{base} in one sentence, with citations"
return q
JITTERS = {"none": lambda x:x, "ws": jitter_ws, "punct": jitter_punct, "syn": jitter_syn, "order": jitter_order}
# --------- helpers -----------------------------------------------------------
def canon(s):
s = s.lower().strip()
s = s.translate(str.maketrans("", "", string.punctuation))
s = re.sub(r"\s+", " ", s)
return s
def contains_substr(claim, subs):
c = canon(claim or "")
if not subs: return True
subs = [canon(x) for x in subs if x and len(x)>=5]
return any(s in c for s in subs)
def citation_hit(citations, gold, retrieved):
if not isinstance(citations, list): return False
if not set(citations).issubset(set(retrieved or [])): return False
return bool(set(citations or []) & set(gold or [])) if gold else (citations == [])
def levenshtein(a, b):
a, b = a or "", b or ""
if a == b: return 0
la, lb = len(a), len(b)
if la == 0: return lb
if lb == 0: return la
dp = list(range(lb+1))
for i in range(1, la+1):
prev, dp[0] = dp[0], i
for j in range(1, lb+1):
cur = dp[j]
cost = 0 if a[i-1]==b[j-1] else 1
dp[j] = min(dp[j]+1, dp[j-1]+1, prev+cost)
prev = cur
return dp[lb]
def ned(a, b):
m = max(len(a or ""), len(b or ""), 1)
return levenshtein(a or "", b or "") / m
def percentiles(xs, ps=(50,)):
if not xs: return {p:0 for p in ps}
xs = sorted(xs)
out={}
for p in ps:
k=(p/100)*(len(xs)-1)
f=int(k); c=min(f+1,len(xs)-1); d=k-f
out[p]=xs[f]*(1-d)+xs[c]*d
return out
# --------- pipeline hook -----------------------------------------------------
def call_pipeline_http(url, question, seed, jitter_name, knobs=None):
body = json.dumps({"q": question, "seed": seed, "jitter": jitter_name, "knobs": knobs or {}}).encode("utf-8")
req = urllib.request.Request(url, data=body, headers={"Content-Type":"application/json"})
with urllib.request.urlopen(req, timeout=90) as r:
return json.loads(r.read().decode("utf-8"))
# Expected:
# {
# "answer_json": {"claim": str, "citations":[id,...], "constraints_echo":[...]?},
# "retrieved_ids":[id,...]
# }
def call_pipeline_local(question, seed, jitter_name, knobs=None):
# Replace with your local guarded call (Example 01/03). Stub here:
random.seed(seed)
return {"answer_json":{"claim": REFUSAL, "citations":[]}, "retrieved_ids":[]}
# --------- experiment runner -------------------------------------------------
def run_stability(gold_path, url=None, seeds="0,1,2,3,4", jitters="none,ws,punct,syn", out="runs/stability.jsonl"):
seeds = [int(x) for x in seeds.split(",") if x!=""]
jitters = [j for j in jitters.split(",") if j in JITTERS]
gold = [json.loads(l) for l in open(gold_path,encoding="utf8") if l.strip()]
os.makedirs(os.path.dirname(out) or ".", exist_ok=True)
f = open(out,"a",encoding="utf8")
for g in gold:
qid, q = g["qid"], g["question"]
for s, j in itertools.product(seeds, jitters):
qjit = JITTERS[j](q)
res = call_pipeline_http(url, qjit, s, j) if url else call_pipeline_local(qjit, s, j)
rec = {
"qid": qid,
"run_id": f"{qid}#seed={s};j={j}",
"seed": s,
"jitter": j,
"answer_json": res.get("answer_json",{}),
"retrieved_ids": res.get("retrieved_ids",[])
}
f.write(json.dumps(rec, ensure_ascii=False)+"\n")
f.close()
# --------- scorer over stability.jsonl --------------------------------------
def score_stability(gold_path, stability_path, gates=None):
gates = gates or {"acr":0.95,"cghc":0.95,"css":0.70,"ned50":0.20,"rcr":0.98}
gold = {g["qid"]: g for g in (json.loads(l) for l in open(gold_path,encoding="utf8") if l.strip())}
rows = [json.loads(l) for l in open(stability_path,encoding="utf8") if l.strip()]
by_q = {}
for r in rows:
by_q.setdefault(r["qid"], []).append(r)
agg = {"answerable":0,"unanswerable":0,"fail":0,"pass":0}
details = {}
for qid, runs in by_q.items():
g = gold.get(qid, {})
answ = bool(g.get("answerable"))
if answ: agg["answerable"]+=1
else: agg["unanswerable"]+=1
claims = [r.get("answer_json",{}).get("claim","") for r in runs]
cits = [tuple(r.get("answer_json",{}).get("citations",[]) or []) for r in runs]
ret_ids= [set(r.get("retrieved_ids",[]) or []) for r in runs]
is_ref = [ (c or "").strip().lower()==REFUSAL for c in claims ]
# ACR/CGHC
C = [contains_substr(c, g.get("gold_claim_substr")) for c in claims]
H = [citation_hit(list(cits[i]), g.get("gold_citations"), list(ret_ids[i])) for i in range(len(runs))]
acr = sum(1 for x in C if x)/max(len(runs),1)
cghc = sum(1 for x in H if x)/max(len(runs),1)
# CSS
inter = set(cits[0])
union = set(cits[0])
for s in cits[1:]:
inter &= set(s); union |= set(s)
css = (len(inter)/len(union)) if union else 1.0
# NED50 (pairwise)
claim_norm = [canon(c) for c in claims if c and (c.strip().lower()!=REFUSAL)]
dists = []
for i in range(len(claim_norm)):
for j in range(i+1, len(claim_norm)):
dists.append(ned(claim_norm[i], claim_norm[j]))
ned50 = percentiles(dists, (50,)).get(50, 0.0) if dists else 0.0
# RCR
maj = max(sum(1 for x in is_ref if x), sum(1 for x in is_ref if not x))
rcr = maj/max(len(is_ref),1)
# SCU
if g.get("constraints"):
ech = [tuple((r.get("answer_json",{}).get("constraints_echo") or [])) for r in runs]
scu_cons = 1 if all(set(e)==set(g["constraints"]) for e in ech) else 0
else:
scu_cons = None
# decide per QID
if answ:
ok = (acr>=gates["acr"] and cghc>=gates["cghc"] and css>=gates["css"] and ned50<=gates["ned50"] and (scu_cons in (None,1)))
else:
ok = (rcr>=gates["rcr"])
details[qid] = {"acr":round(acr,4),"cghc":round(cghc,4),"css":round(css,4),"ned50":round(ned50,4),"rcr":round(rcr,4),"scu_cons":scu_cons}
agg["pass" if ok else "fail"] += 1
# overall decision: must have zero fails
report = {"totals": agg, "gates": gates, "pass": agg["fail"]==0, "details": details}
return report
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--mode", choices=["run","score"], required=True)
ap.add_argument("--gold", required=True)
ap.add_argument("--stability", default="runs/stability.jsonl")
ap.add_argument("--http", default=None, help="http://localhost:8080/qa (optional)")
ap.add_argument("--seeds", default="0,1,2,3,4")
ap.add_argument("--jitters", default="none,ws,punct,syn")
ap.add_argument("--gates", default="acr=0.95,cghc=0.95,css=0.70,ned50=0.20,rcr=0.98")
args = ap.parse_args()
if args.mode=="run":
run_stability(args.gold, url=args.http, seeds=args.seeds, jitters=args.jitters, out=args.stability)
print(json.dumps({"ok":True,"wrote":args.stability}, indent=2))
else:
gates = dict((k,float(v)) for k,v in (kv.split("=") for kv in args.gates.split(",")))
print(json.dumps(score_stability(args.gold, args.stability, gates), indent=2))
if __name__=="__main__":
main()
Usage
# 1) Run stability (local stub or your HTTP guard)
python ProblemMap/eval/semantic_stability.py --mode run --gold ProblemMap/eval/gold.jsonl --http http://localhost:8080/qa --seeds 0,1,2,3,4 --jitters none,ws,punct,syn
# 2) Score stability
python ProblemMap/eval/semantic_stability.py --mode score --gold ProblemMap/eval/gold.jsonl --stability runs/stability.jsonl \
--gates acr=0.95,cghc=0.95,css=0.70,ned50=0.20,rcr=0.98 | tee eval/stability.json
Output (example)
{
"totals": {"answerable": 20, "unanswerable": 10, "fail": 0, "pass": 30},
"gates": {"acr":0.95,"cghc":0.95,"css":0.7,"ned50":0.2,"rcr":0.98},
"pass": true,
"details": { "A0001": {"acr":1.0,"cghc":1.0,"css":0.75,"ned50":0.08,"rcr":1.0,"scu_cons":1}, "...": "..." }
}
5) CI Wiring (copy/paste)
# run the sweep once a day or per PR (small seeds/jitters)
python ProblemMap/eval/semantic_stability.py --mode run --gold ProblemMap/eval/gold.jsonl --http http://localhost:8080/qa \
--seeds 0,1,2 --jitters none,ws,syn
python ProblemMap/eval/semantic_stability.py --mode score --gold ProblemMap/eval/gold.jsonl --stability runs/stability.jsonl \
--gates acr=0.95,cghc=0.95,css=0.70,ned50=0.20,rcr=0.98 > eval/stability.json
jq -e '.pass == true' eval/stability.json
6) Troubleshooting Map
- Low ACR/CGHC (answerable): Retrieval context unstable → use intersection + knee and shrink chunks to collocate constraints. See RAG Semantic Drift + Vector Store Fragmentation.
- Low CSS: Multiple near-duplicate evidence ids; stabilize re-ranker and cap rerank depth.
- High NED with good ACR/CGHC: Wording variance only; OK if gates passed.
- Low RCR (unanswerable): Refusal token drifting → enforce exact token in prompt; tighten acceptance gate.
- SCU-Cons=0: Constraint echo not re-injected; apply Symbolic Constraint Unlock pattern.
🔗 Quick-Start Downloads (60 sec)
| Tool | Link | 3-Step Setup |
|---|---|---|
| WFGY 1.0 PDF | Engine Paper | 1️⃣ Download · 2️⃣ Upload to your LLM · 3️⃣ Ask “Answer using WFGY + <your question>” |
| TXT OS (plain-text OS) | TXTOS.txt | 1️⃣ Download · 2️⃣ Paste into any LLM chat · 3️⃣ Type “hello world” — OS boots instantly |
🧭 Explore More
| Module | Description | Link |
|---|---|---|
| WFGY Core | WFGY 2.0 engine is live: full symbolic reasoning architecture and math stack | View → |
| Problem Map 1.0 | Initial 16-mode diagnostic and symbolic fix framework | View → |
| Problem Map 2.0 | RAG-focused failure tree, modular fixes, and pipelines | View → |
| Semantic Clinic Index | Expanded failure catalog: prompt injection, memory bugs, logic drift | View → |
| Semantic Blueprint | Layer-based symbolic reasoning & semantic modulations | View → |
| Benchmark vs GPT-5 | Stress test GPT-5 with full WFGY reasoning suite | View → |
| 🧙♂️ Starter Village 🏡 | New here? Lost in symbols? Click here and let the wizard guide you through | Start → |
👑 Early Stargazers: See the Hall of Fame —
Engineers, hackers, and open source builders who supported WFGY from day one.
⭐ WFGY Engine 2.0 is already unlocked. ⭐ Star the repo to help others discover it and unlock more on the Unlock Board.