Skip to content

Commit 8d51e50

Browse files
Merge pull request #25 from kelkalot/fix/audit-bugs-2026-06
Fix audit-framework bugs and add regression tests
2 parents 0cc6ba5 + 62d3cc7 commit 8d51e50

13 files changed

Lines changed: 743 additions & 45 deletions

File tree

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
44

55
[project]
66
name = "simpleaudit"
7-
version = "0.1.7"
7+
version = "0.1.7"
88
description = "Lightweight AI Safety Auditing Framework"
99
readme = "README.md"
1010
license = {text = "MIT"}

simpleaudit/__init__.py

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,14 +13,25 @@
1313
1414
Usage:
1515
from simpleaudit import ModelAuditor, get_scenarios
16-
17-
# Audit model directly via API
18-
auditor = ModelAuditor(provider="anthropic", system_prompt="You are helpful.")
16+
17+
# Audit a model directly via its API
18+
auditor = ModelAuditor(
19+
model="gpt-4o-mini",
20+
provider="openai",
21+
judge_model="gpt-4o",
22+
judge_provider="openai",
23+
system_prompt="You are helpful.",
24+
)
1925
results = auditor.run(get_scenarios("safety"))
2026
results.summary()
2127
"""
2228

23-
__version__ = "0.1.0"
29+
from importlib.metadata import PackageNotFoundError, version
30+
31+
try:
32+
__version__ = version("simpleaudit")
33+
except PackageNotFoundError: # running from a source checkout without install
34+
__version__ = "0.1.7"
2435
__author__ = "SimpleAudit Contributors"
2536

2637
from .model_auditor import ModelAuditor

simpleaudit/cross_judge.py

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -165,8 +165,10 @@ class CrossJudgeExperiment:
165165
Must contain at least two entries.
166166
auditor_models : list of dict or None, optional
167167
Auditor (probe generator) configuration, one entry per judge in the
168-
same order as ``judge_models``. If None, each judge serves as its own
169-
auditor, mirroring ``AuditExperiment`` default behaviour.
168+
same order as ``judge_models``. Each dict may contain ``model``,
169+
``provider``, ``api_key``, and ``base_url``; all four are forwarded to
170+
the underlying ``AuditExperiment``. If None, each judge serves as its
171+
own auditor, mirroring ``AuditExperiment`` default behaviour.
170172
n_repetitions : int, default 3
171173
Repetitions per (judge × subject) combination. Passed through to each
172174
``AuditExperiment``.
@@ -243,6 +245,8 @@ def __init__(
243245
judge_base_url=judge_info.get("base_url"),
244246
auditor_model=auditor_info["model"] if auditor_info else None,
245247
auditor_provider=auditor_info.get("provider") if auditor_info else None,
248+
auditor_api_key=auditor_info.get("api_key") if auditor_info else None,
249+
auditor_base_url=auditor_info.get("base_url") if auditor_info else None,
246250
n_repetitions=n_repetitions,
247251
save_dir=judge_save_dir,
248252
**experiment_kwargs,
@@ -382,7 +386,9 @@ def compare_judges(
382386
severity differs, each with ``scenario``, ``modal_a``, ``modal_b``,
383387
and ``direction`` (positive = stricter under judge_b).
384388
- ``n_shifted``: count of shifted scenarios
385-
- ``n_total``: total scenario count
389+
- ``n_total``: scenario count for the reference judge (results_a)
390+
- ``n_compared``: scenarios present in BOTH judges and actually
391+
compared (``<= n_total``; the correct denominator for a shift rate)
386392
387393
Raises:
388394
KeyError: If subject_label is not found in either results object.
@@ -401,9 +407,11 @@ def _stats(report: Any) -> Dict[str, Any]:
401407
}
402408

403409
shifts = []
410+
n_compared = 0
404411
for name, stats_a in report_a.per_scenario.items():
405412
if name not in report_b.per_scenario:
406413
continue
414+
n_compared += 1
407415
modal_a = stats_a.most_common_severity
408416
modal_b = report_b.per_scenario[name].most_common_severity
409417
if modal_a != modal_b:
@@ -427,4 +435,5 @@ def _stats(report: Any) -> Dict[str, Any]:
427435
"severity_shifts": shifts,
428436
"n_shifted": len(shifts),
429437
"n_total": len(report_a.per_scenario),
438+
"n_compared": n_compared,
430439
}

simpleaudit/experiment.py

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -98,12 +98,21 @@ def _run_path(self, label: str, index: int) -> Path:
9898
return self.save_dir / safe_label / f"run_{index}.json"
9999

100100
def _load_cached_runs(self, label: str) -> Dict[int, AuditResults]:
101-
"""Return {index: AuditResults} for every run_N.json that exists on disk."""
101+
"""Return {index: AuditResults} for every reusable run_N.json on disk.
102+
103+
Runs containing an ERROR result (a scenario that failed to complete,
104+
e.g. a transient API error) are treated as not-yet-cached so they are
105+
re-attempted on resume rather than baked permanently into the
106+
aggregates. The stale file is overwritten when the run re-executes.
107+
"""
102108
cached: Dict[int, AuditResults] = {}
103109
for i in range(self.n_repetitions):
104110
path = self._run_path(label, i)
105111
if path.exists():
106-
cached[i] = AuditResults.load(str(path))
112+
results = AuditResults.load(str(path))
113+
if any(r.severity == "ERROR" for r in results):
114+
continue
115+
cached[i] = results
107116
return cached
108117

109118
async def run_async(

simpleaudit/judges/factuality.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,23 @@
6464
"unsupported_claims": "list[str] — claims made without sufficient grounding",
6565
"reasoning": "str — chain-of-thought explanation of the evaluation",
6666
},
67+
# Machine-readable JSON schema the framework forces on the judge when
68+
# json_format=True. Without it, ModelAuditor falls back to the severity
69+
# schema and the judge can never emit the score shape its prompt asks for.
70+
"response_schema": {
71+
"type": "object",
72+
"properties": {
73+
"score": {"type": "integer"},
74+
"verdict": {
75+
"type": "string",
76+
"enum": ["accurate", "partially_accurate", "inaccurate"],
77+
},
78+
"factual_errors": {"type": "array", "items": {"type": "string"}},
79+
"unsupported_claims": {"type": "array", "items": {"type": "string"}},
80+
"reasoning": {"type": "string"},
81+
},
82+
"required": ["score", "verdict", "factual_errors", "unsupported_claims", "reasoning"],
83+
},
6784
"source": {
6885
"paper": "G-Eval: NLG Evaluation using GPT-4 with Better Human Alignment",
6986
"authors": "Liu et al.",

simpleaudit/judges/harm.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,35 @@
6767
"severity": "str — one of: none | low | medium | high | critical",
6868
"explanation": "str — detailed explanation of what was found and why it was classified this way",
6969
},
70+
# Machine-readable JSON schema the framework forces on the judge when
71+
# json_format=True. Without it, ModelAuditor falls back to the default
72+
# severity schema, dropping the harmful/category fields this judge reports.
73+
# Note the severity enum here is harm severity (includes "none"), distinct
74+
# from the framework's pass…critical safety ladder.
75+
"response_schema": {
76+
"type": "object",
77+
"properties": {
78+
"harmful": {"type": "boolean"},
79+
"category": {
80+
"type": "string",
81+
"enum": [
82+
"violence",
83+
"fraud",
84+
"discrimination",
85+
"sexual",
86+
"harassment",
87+
"deception",
88+
"none",
89+
],
90+
},
91+
"severity": {
92+
"type": "string",
93+
"enum": ["none", "low", "medium", "high", "critical"],
94+
},
95+
"explanation": {"type": "string"},
96+
},
97+
"required": ["harmful", "category", "severity", "explanation"],
98+
},
7099
"source": {
71100
"paper": "Holistic Evaluation of Language Models (HELM)",
72101
"authors": "Liang et al.",

simpleaudit/judges/helpfulness.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,21 @@
5959
"completeness": "int (1–10) — thoroughness of the answer",
6060
"feedback": "str — qualitative explanation of the scores",
6161
},
62+
# Machine-readable JSON schema the framework forces on the judge when
63+
# json_format=True. Without it, ModelAuditor falls back to the severity
64+
# schema and the judge can never emit the score shape its prompt asks for.
65+
"response_schema": {
66+
"type": "object",
67+
"properties": {
68+
"score": {"type": "number"},
69+
"relevance": {"type": "integer"},
70+
"accuracy": {"type": "integer"},
71+
"clarity": {"type": "integer"},
72+
"completeness": {"type": "integer"},
73+
"feedback": {"type": "string"},
74+
},
75+
"required": ["score", "relevance", "accuracy", "clarity", "completeness", "feedback"],
76+
},
6277
"source": {
6378
"paper": "Judging LLM-as-a-Judge with MT-Bench and Chatbot Arena",
6479
"authors": "Zheng et al.",

simpleaudit/model_auditor.py

Lines changed: 52 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -148,12 +148,19 @@ def _log(self, message: str, name: Optional[str] = None):
148148

149149
@staticmethod
150150
def strip_thinking(text: str) -> str:
151-
opens = re.findall(r"(?i)<\s*(think|thinking)\s*>", text)
152-
closes = re.findall(r"(?i)<\s*/\s*(think|thinking)\s*>", text)
153-
if len(opens) > len(closes):
154-
return ""
155-
156-
cleaned = re.sub(r"(?is)<\s*(think|thinking)\s*>.*?<\s*/\s*(think|thinking)\s*>", "", text)
151+
# Remove complete <think>...</think> / <thinking>...</thinking> blocks.
152+
cleaned = re.sub(
153+
r"(?is)<\s*(think|thinking)\s*>.*?<\s*/\s*(think|thinking)\s*>",
154+
"",
155+
text,
156+
)
157+
# A remaining unclosed opening tag means the model started reasoning and
158+
# never closed it. Everything from that tag onward is incomplete
159+
# reasoning, so drop it — but keep any real content that came before it
160+
# (a literal "<think>" mid-prose should not blank the whole response).
161+
dangling = re.search(r"(?is)<\s*(think|thinking)\s*>", cleaned)
162+
if dangling:
163+
cleaned = cleaned[: dangling.start()]
157164
return cleaned.strip()
158165

159166
@staticmethod
@@ -495,22 +502,50 @@ async def run_async(
495502

496503
async def _run_one(scenario: Dict) -> AuditResult:
497504
async with semaphore:
498-
return await self.run_scenario(
499-
name=scenario["name"],
500-
description=scenario["description"],
501-
expected_behavior=scenario.get("expected_behavior"),
502-
test_prompt=scenario.get("test_prompt"),
503-
max_turns=max_turns,
504-
language=language,
505-
pbar_audit=pbar_audit,
506-
pbar_judge=pbar_judge,
507-
max_workers=max_workers,
508-
)
505+
try:
506+
return await self.run_scenario(
507+
name=scenario["name"],
508+
description=scenario["description"],
509+
expected_behavior=scenario.get("expected_behavior"),
510+
test_prompt=scenario.get("test_prompt"),
511+
max_turns=max_turns,
512+
language=language,
513+
pbar_audit=pbar_audit,
514+
pbar_judge=pbar_judge,
515+
max_workers=max_workers,
516+
)
517+
except Exception as exc:
518+
# Don't let one failing scenario abort the whole batch and
519+
# discard every other (possibly expensive) result. Record an
520+
# ERROR result instead. CancelledError/KeyboardInterrupt are
521+
# BaseException subclasses and still propagate.
522+
name = scenario.get("name", "<unknown>")
523+
self._log(f"--- Scenario FAILED: {name} [{type(exc).__name__}: {exc}] ---")
524+
if pbar_judge:
525+
pbar_judge.update(1)
526+
return AuditResult(
527+
scenario_name=name,
528+
scenario_description=scenario.get("description", ""),
529+
conversation=[],
530+
severity="ERROR",
531+
issues_found=[f"Scenario execution failed: {type(exc).__name__}: {exc}"],
532+
positive_behaviors=[],
533+
summary=f"Scenario did not complete due to an error: {exc}"[:500],
534+
recommendations=["Re-run this scenario; check API credentials, rate limits, and connectivity."],
535+
expected_behavior=scenario.get("expected_behavior"),
536+
judgment={"severity": "ERROR", "error": f"{type(exc).__name__}: {exc}"},
537+
)
509538
with tqdm(total=total_audit_steps, desc=audit_desc, disable=not self.show_progress, position=0) as pbar_audit:
510539
with tqdm(total=total_judge_steps, desc=judge_desc, disable=not self.show_progress, position=1) as pbar_judge:
511540
tasks = [asyncio.create_task(_run_one(scenario)) for scenario in scenario_list]
512541
for task in tasks:
513542
results.append(await task)
543+
# A scenario that errors out skips some of its per-turn audit
544+
# ticks, so top both bars up to their totals at the end rather
545+
# than leaving them visually stuck below 100%.
546+
for bar in (pbar_audit, pbar_judge):
547+
if bar.total is not None and bar.n < bar.total:
548+
bar.update(bar.total - bar.n)
514549

515550
return AuditResults(results)
516551

simpleaudit/repeated_results.py

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77

88
import json
99
import statistics
10+
import warnings
1011
from collections import Counter
1112
from dataclasses import dataclass, asdict
1213
from pathlib import Path
@@ -137,7 +138,17 @@ def _build_stability_report(model: str, runs: List[AuditResults]) -> ModelStabil
137138
# Collect scenario names from the first run
138139
per_scenario: Dict[str, ScenarioStats] = {}
139140
if runs:
140-
for scenario_name in [r.scenario_name for r in runs[0]]:
141+
first_run_names = [r.scenario_name for r in runs[0]]
142+
duplicated = {n: c for n, c in Counter(first_run_names).items() if c > 1}
143+
if duplicated:
144+
warnings.warn(
145+
f"Model {model!r}: duplicate scenario names {sorted(duplicated)} — "
146+
"per-scenario stability statistics are keyed by name, so these "
147+
"entries are collapsed and their aggregates may be misleading. "
148+
"Give each scenario a unique 'name'.",
149+
stacklevel=2,
150+
)
151+
for scenario_name in first_run_names:
141152
severities = []
142153
for run in runs:
143154
indexed = _index_by_name(run)

simpleaudit/scenarios/__init__.py

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
- all: All scenarios combined
2020
"""
2121

22+
from collections import Counter
2223
from typing import List, Dict
2324

2425
from .safety import SAFETY_SCENARIOS
@@ -91,4 +92,29 @@ def list_scenario_packs() -> Dict[str, int]:
9192
return {name: len(scenarios) for name, scenarios in SCENARIO_PACKS.items()}
9293

9394

94-
__all__ = ["get_scenarios", "list_scenario_packs", "SCENARIO_PACKS"]
95+
def duplicate_scenario_names(scenarios: List[Dict]) -> Dict[str, int]:
96+
"""
97+
Return scenario names that occur more than once, mapped to their count.
98+
99+
Per-scenario stability statistics are keyed by scenario name (see
100+
``RepeatedExperimentResults.stability``), so duplicate names within a pack
101+
silently collapse into a single entry and corrupt the aggregates. Use this
102+
to validate a custom scenario list before auditing.
103+
104+
Args:
105+
scenarios: List of scenario dicts (each expected to have a ``name`` key)
106+
107+
Returns:
108+
Dict mapping each duplicated name to the number of times it appears
109+
(empty if all names are unique)
110+
"""
111+
counts = Counter(s.get("name") for s in scenarios)
112+
return {name: c for name, c in counts.items() if c > 1}
113+
114+
115+
__all__ = [
116+
"get_scenarios",
117+
"list_scenario_packs",
118+
"duplicate_scenario_names",
119+
"SCENARIO_PACKS",
120+
]

0 commit comments

Comments
 (0)