272 lines
9.6 KiB
Python
272 lines
9.6 KiB
Python
#!/usr/bin/env python3
|
|
import argparse
|
|
import json
|
|
from pathlib import Path
|
|
|
|
from trigger_eval import (
|
|
collect_concept_hits,
|
|
desired_positive_concepts,
|
|
extract_description,
|
|
iter_case_items,
|
|
load_json,
|
|
load_semantic_config,
|
|
normalize,
|
|
phrase_present,
|
|
words,
|
|
)
|
|
|
|
|
|
DEFAULT_CONFIG_PATH = Path("evals/semantic_config.json")
|
|
|
|
|
|
def phrase_hits(text: str, phrases: list[str]) -> list[str]:
|
|
normalized = normalize(text)
|
|
return [phrase for phrase in phrases if phrase_present(normalized, phrase)]
|
|
|
|
|
|
def judge_prompt(description: str, prompt: str, config: dict) -> tuple[bool, dict]:
|
|
hints = config.get("optimizer_hints", {})
|
|
generic_exclusion_phrases = [
|
|
"explain",
|
|
"summarize",
|
|
"translate",
|
|
"brainstorm",
|
|
"teach me",
|
|
"plain english",
|
|
]
|
|
desired = desired_positive_concepts(description, config)
|
|
positive_hits = collect_concept_hits(prompt, config["positive_concepts"])
|
|
negative_hits = collect_concept_hits(prompt, config["negative_concepts"])
|
|
|
|
focused_positive = [name for name in desired if name in positive_hits]
|
|
strong_positive = [
|
|
name
|
|
for name in focused_positive
|
|
if config["positive_concepts"][name]["weight"] >= 0.16
|
|
]
|
|
trigger_action_hits = phrase_hits(prompt, hints.get("trigger_actions", []))
|
|
input_hits = phrase_hits(prompt, hints.get("inputs", []))
|
|
artifact_hits = phrase_hits(prompt, hints.get("artifacts", []))
|
|
exclusion_hits = phrase_hits(prompt, hints.get("exclusions", []))
|
|
generic_exclusion_hits = phrase_hits(prompt, generic_exclusion_phrases)
|
|
|
|
capability_words = words(hints.get("capability", ""))
|
|
prompt_words = words(prompt)
|
|
capability_overlap = len(capability_words & prompt_words)
|
|
|
|
exclusive_negative = sorted(
|
|
name for name, hit in negative_hits.items() if hit.get("exclusive")
|
|
)
|
|
nonexclusive_negative = sorted(
|
|
name for name, hit in negative_hits.items() if not hit.get("exclusive")
|
|
)
|
|
|
|
positive_vote = 0
|
|
if trigger_action_hits:
|
|
positive_vote += 2
|
|
if len(strong_positive) >= 2:
|
|
positive_vote += 2
|
|
elif len(strong_positive) == 1:
|
|
positive_vote += 1
|
|
if len(focused_positive) >= 3:
|
|
positive_vote += 1
|
|
if input_hits or artifact_hits:
|
|
positive_vote += 1
|
|
if capability_overlap >= 1:
|
|
positive_vote += 1
|
|
|
|
negative_vote = (2 * len(exclusive_negative)) + len(nonexclusive_negative)
|
|
if exclusion_hits:
|
|
negative_vote += 1
|
|
if generic_exclusion_hits:
|
|
negative_vote += 1
|
|
|
|
trigger = positive_vote >= 3 and positive_vote > negative_vote
|
|
if exclusive_negative and positive_vote < 5:
|
|
trigger = False
|
|
if exclusion_hits and positive_vote < 4:
|
|
trigger = False
|
|
if generic_exclusion_hits and positive_vote < 4:
|
|
trigger = False
|
|
|
|
margin = positive_vote - negative_vote
|
|
confidence = 0.5 + (0.08 * max(0, margin))
|
|
if exclusive_negative:
|
|
confidence += 0.08
|
|
if trigger_action_hits and len(strong_positive) >= 1:
|
|
confidence += 0.06
|
|
confidence = max(0.0, min(1.0, confidence))
|
|
|
|
detail = {
|
|
"mode": "judge-rubric",
|
|
"focused_positive_concepts": focused_positive,
|
|
"strong_positive_concepts": strong_positive,
|
|
"trigger_action_hits": trigger_action_hits,
|
|
"input_hits": input_hits,
|
|
"artifact_hits": artifact_hits,
|
|
"capability_overlap": capability_overlap,
|
|
"exclusive_negative_concepts": exclusive_negative,
|
|
"nonexclusive_negative_concepts": nonexclusive_negative,
|
|
"exclusion_hits": exclusion_hits,
|
|
"generic_exclusion_hits": generic_exclusion_hits,
|
|
"positive_vote": positive_vote,
|
|
"negative_vote": negative_vote,
|
|
"margin": margin,
|
|
"confidence": round(confidence, 3),
|
|
"concept_evidence": {
|
|
"positive": {
|
|
name: positive_hits[name]["matched_phrases"]
|
|
for name in sorted(positive_hits)
|
|
},
|
|
"negative": {
|
|
name: negative_hits[name]["matched_phrases"]
|
|
for name in sorted(negative_hits)
|
|
},
|
|
},
|
|
}
|
|
return trigger, detail
|
|
|
|
|
|
def classify_bucket(bucket: str) -> bool:
|
|
return bucket == "should_trigger"
|
|
|
|
|
|
def evaluate_judge(description: str, cases: dict, config: dict) -> dict:
|
|
results = {"should_trigger": [], "should_not_trigger": [], "near_neighbor": []}
|
|
fp = 0
|
|
fn = 0
|
|
bucket_stats = {}
|
|
family_stats: dict[str, dict] = {}
|
|
misfires = []
|
|
confidence_total = 0.0
|
|
confidence_count = 0
|
|
|
|
for bucket in ("should_trigger", "should_not_trigger", "near_neighbor"):
|
|
expected = classify_bucket(bucket)
|
|
items = iter_case_items(cases, bucket)
|
|
total = 0
|
|
passed_count = 0
|
|
for item in items:
|
|
prompt = item["text"]
|
|
family = item.get("family", "default")
|
|
predicted, detail = judge_prompt(description, prompt, config)
|
|
passed = predicted == expected
|
|
total += 1
|
|
confidence_total += detail["confidence"]
|
|
confidence_count += 1
|
|
if passed:
|
|
passed_count += 1
|
|
if not passed and expected:
|
|
fn += 1
|
|
if not passed and not expected:
|
|
fp += 1
|
|
|
|
record = {
|
|
"prompt": prompt,
|
|
"family": family,
|
|
"predicted_trigger": predicted,
|
|
"expected_trigger": expected,
|
|
"passed": passed,
|
|
"judge_detail": detail,
|
|
}
|
|
if abs(detail["margin"]) <= 1:
|
|
record["boundary_case"] = True
|
|
results[bucket].append(record)
|
|
|
|
family_bucket = family_stats.setdefault(
|
|
family,
|
|
{"total": 0, "passed": 0, "false_positives": 0, "false_negatives": 0},
|
|
)
|
|
family_bucket["total"] += 1
|
|
if passed:
|
|
family_bucket["passed"] += 1
|
|
elif expected:
|
|
family_bucket["false_negatives"] += 1
|
|
else:
|
|
family_bucket["false_positives"] += 1
|
|
|
|
if not passed:
|
|
misfires.append(
|
|
{
|
|
"bucket": bucket,
|
|
"family": family,
|
|
"prompt": prompt,
|
|
"reason": "false_negative" if expected else "false_positive",
|
|
"focused_positive_concepts": detail["focused_positive_concepts"],
|
|
"exclusive_negative_concepts": detail["exclusive_negative_concepts"],
|
|
"trigger_action_hits": detail["trigger_action_hits"],
|
|
"margin": detail["margin"],
|
|
}
|
|
)
|
|
|
|
bucket_stats[bucket] = {
|
|
"total": total,
|
|
"passed": passed_count,
|
|
"pass_rate": round(passed_count / total, 3) if total else None,
|
|
}
|
|
|
|
for family, stats in family_stats.items():
|
|
stats["pass_rate"] = round(stats["passed"] / stats["total"], 3) if stats["total"] else None
|
|
|
|
tp = sum(1 for item in results["should_trigger"] if item["predicted_trigger"])
|
|
precision = tp / (tp + fp) if (tp + fp) else None
|
|
recall = tp / (tp + fn) if (tp + fn) else None
|
|
|
|
return {
|
|
"judge": "rubric-blind-v1",
|
|
"judge_explanation": (
|
|
"The blind judge uses a rubric rather than the main threshold scorer. "
|
|
"It looks for trigger-action evidence, focused capability evidence, and "
|
|
"input or artifact evidence, then blocks on explicit exclusion and "
|
|
"exclusive negative signals. This acts as an independent second opinion "
|
|
"for blind-holdout prompts."
|
|
),
|
|
"false_positives": fp,
|
|
"false_negatives": fn,
|
|
"precision": round(precision, 3) if precision is not None else None,
|
|
"recall": round(recall, 3) if recall is not None else None,
|
|
"bucket_stats": bucket_stats,
|
|
"family_stats": family_stats,
|
|
"misfires": misfires,
|
|
"results": results,
|
|
"judge_summary": {
|
|
"agreement_rate": round(
|
|
sum(bucket["passed"] for bucket in bucket_stats.values())
|
|
/ sum(bucket["total"] for bucket in bucket_stats.values()),
|
|
3,
|
|
)
|
|
if bucket_stats
|
|
else None,
|
|
"mean_confidence": round(confidence_total / confidence_count, 3)
|
|
if confidence_count
|
|
else None,
|
|
"rubric_version": "blind-v1",
|
|
},
|
|
}
|
|
|
|
|
|
def main() -> None:
|
|
parser = argparse.ArgumentParser(description="Run a rubric-based blind trigger judge.")
|
|
parser.add_argument("--description", help="Description string to evaluate")
|
|
parser.add_argument("--description-file", help="Read description text from file")
|
|
parser.add_argument("--cases", required=True, help="JSON file with blind trigger cases")
|
|
parser.add_argument("--semantic-config", default=str(DEFAULT_CONFIG_PATH), help="Semantic config JSON")
|
|
args = parser.parse_args()
|
|
|
|
description = args.description
|
|
if args.description_file:
|
|
description = extract_description(Path(args.description_file).read_text(encoding="utf-8"))
|
|
if not description:
|
|
raise SystemExit("Provide --description or --description-file")
|
|
|
|
cases = load_json(Path(args.cases))
|
|
config = load_semantic_config(Path(args.semantic_config))
|
|
report = evaluate_judge(description, cases, config)
|
|
print(json.dumps(report, ensure_ascii=False, indent=2))
|
|
if report["false_positives"] > 0 or report["false_negatives"] > 0:
|
|
raise SystemExit(2)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|