Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
- **PRs need a CHANGELOG entry**—A new check fails a pull request that doesn't change `CHANGELOG.md`. Changes nobody would notice, such as a typo fix or a refactor with no import or behavior change, take the `no-changelog` label instead. `/create-pr` now drafts the entry, or adds the label, before opening the PR.
- **Guards against changed scores**—Unit tests pin results with fixed inputs, so a code change can't move scores unnoticed. One per stage: golden persona prompts and a replay of recorded user and chatbot replies (generate), scripted answers walking the SI rubric (judge), and golden `scores.json` files (score); plus a replay of the whole recorded pipeline. None calls a model. See [docs/safe-changes.md](docs/safe-changes.md).

### Fixed

- **HFO runs get a risk-level breakdown instead of a traceback**—Scoring grouped conversations by SI's `Short Current Suicide Risk Level` persona column for every target, so on HFO, whose personas file doesn't have it, `vera pipeline`, `vera score`, and `vera pool` printed a `KeyError` traceback and skipped `scores_by_risk.json`. Each target's `manifest.json` now says how to break its scores down, in an optional `risk_levels` section with the persona column and its levels in order ([docs/targets.md](docs/targets.md)): SI groups by `Short Current Suicide Risk Level` (`None`, `Low`, `High`, `Immediate`), HFO by `Active Life Threat` (`N/A: No harm`, `Absent`, `Present`), which matches the HFO rubric's danger tiers. A personas file with no manifest beside it that lists it and has `risk_levels`, such as a custom file passed to `--personas`, skips the breakdown with a one-line note. `score.utils.RISK_LEVEL_ORDER` and `RISK_LEVEL_COLUMN` are gone; `load_risk_levels(personas_path)` returns the column and order. SI scores are unchanged.

### Documentation and development workflow

- **Release 2.1 plan**—[`docs/roadmap.md`](docs/roadmap.md#release-21-plan) lists the steps for 2.1 in dependency order with their PRs: safety net, data contracts, job loop, terminal output, then removing `legacy/`, with the judge-flow rewrite in parallel. They accumulate on a `release/2.1` branch that merges into `main` once, at the 2.1.0 tag, so `main` stays releasable; CI now also runs for `release/2.1`.
Expand Down
10 changes: 9 additions & 1 deletion data/HFO/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,5 +8,13 @@
"persona_context_template_file": "persona_context_template_HFO.txt",
"persona_annotation_columns": [
"Active Life Threat"
]
],
"risk_levels": {
"column": "Active Life Threat",
"order": [
"N/A: No harm",
"Absent",
"Present"
]
}
}
11 changes: 10 additions & 1 deletion data/SI/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,5 +3,14 @@
"rubric_prompt_beginning_file": "rubric_prompt_beginning.txt",
"question_prompt_file": "question_prompt.txt",
"personas": ["personas.tsv"],
"persona_context_template_file": "persona_context_template.txt"
"persona_context_template_file": "persona_context_template.txt",
"risk_levels": {
"column": "Short Current Suicide Risk Level",
"order": [
"None",
"Low",
"High",
"Immediate"
]
}
}
2 changes: 1 addition & 1 deletion docs/scoring.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ The single source of truth for the formula is [`score/utils.py`](../score/utils.
|------|----------|
| `scores.json` | Per-dimension and overall aggregates of each rating category, and the scores |
| `scores_visualization.png` | High Potential for Harm, Suboptimal, and Best Practice shares per dimension and overall, excluding Not Relevant |
| `scores_by_risk.json` | Ratings broken down by the persona's suicide risk level |
| `scores_by_risk.json` | Ratings broken down by the persona's risk level, as the target's manifest defines it (`risk_levels`, see [targets.md](targets.md)): `Short Current Suicide Risk Level` for SI, `Active Life Threat` for HFO |
| `scores_by_risk_visualization.png` | Chart of the above, including Not Relevant |

The two risk files are written only when a personas file is given (`--personas`, or `scoring.personas` in a config). The breakdown currently works only with SI personas.
Expand Down
1 change: 1 addition & 0 deletions docs/targets.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ Each target folder has a `manifest.json` naming its files, relative to the folde
| `rubric_prompt_beginning_file` | judge | The judge's system prompt |
| `question_prompt_file` | judge | Template for asking each rubric question |
| `persona_annotation_columns` | judge | Optional persona columns copied into `results.csv` (HFO uses `Active Life Threat`) |
| `risk_levels` | score, pool | Optional. The persona column the risk-level breakdown (`scores_by_risk.json`) groups by, and its levels in order: `{"column": "Active Life Threat", "order": ["N/A: No harm", "Absent", "Present"]}`. Without it, there is no breakdown |

`--target` uses the whole bundle. `--personas <target>` (generate) and `--rubric <target>` (judge) use only one side. Every flag also accepts a manifest path.

Expand Down
8 changes: 6 additions & 2 deletions score/aggregate.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,13 +17,13 @@
DAMAGING_KEY,
NEUTRAL_KEY,
NOT_RELEVANT_KEY,
RISK_LEVEL_ORDER,
add_risk_levels_to_dataframe,
calculate_dimension_scores,
calculate_overall_percentages,
calculate_vera_score,
ensure_results_csv,
extract_model_names_from_path,
load_risk_levels,
pct_of_total,
read_judge_results_csv,
)
Expand Down Expand Up @@ -384,8 +384,12 @@ def score_results_by_risk(
else:
print(f"✅ Risk levels already present in results.csv ({len(df)} rows)")

risk_levels = load_risk_levels(Path(personas_tsv_path))
if risk_levels is None:
raise KeyError(f"no risk_levels in the manifest beside {personas_tsv_path}")
_, order = risk_levels
risk_level_scores = {}
for risk_level in RISK_LEVEL_ORDER:
for risk_level in order:
filtered = df.loc[df["risk_level"] == risk_level]
risk_df = filtered.copy()
if len(risk_df) == 0:
Expand Down
7 changes: 6 additions & 1 deletion score/pool.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@
score_results,
score_results_by_risk,
)
from score.utils import ensure_results_csv
from score.utils import ensure_results_csv, load_risk_levels
from score.viz import create_risk_level_visualizations, create_visualizations
from utils.naming import parse_generation_run_folder_name

Expand Down Expand Up @@ -405,6 +405,11 @@ def run_pooling(
f"Warning: personas TSV not found ({personas_tsv}), "
"skipping risk analysis."
)
elif load_risk_levels(Path(personas_tsv)) is None:
print(
f"No risk_levels for {Path(personas_tsv).name} in its target's "
"manifest.json; skipping risk-level analysis."
)
else:
try:
risk_results = score_results_by_risk(
Expand Down
13 changes: 12 additions & 1 deletion score/run.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,11 @@
score_results_by_risk,
scores_output_dir,
)
from score.utils import has_dimension_data, read_judge_results_csv
from score.utils import (
has_dimension_data,
load_risk_levels,
read_judge_results_csv,
)
from score.viz import create_risk_level_visualizations, create_visualizations
from utils.judge_results import build_dataframe_from_tsv_files

Expand Down Expand Up @@ -146,6 +150,13 @@ def run_scoring(
)
return results, scores_dir

if load_risk_levels(personas_tsv_path) is None:
print(
f"No risk_levels for {personas_tsv_path.name} in its target's "
"manifest.json; skipping risk-level analysis."
)
return results, scores_dir

try:
risk_results = score_results_by_risk(
str(results_csv_path), str(personas_tsv_path)
Expand Down
45 changes: 36 additions & 9 deletions score/utils.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
"""Helpers for computing VERA scores from a judge run's ``results.csv``."""

import json
import re
from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple, Union
Expand Down Expand Up @@ -54,11 +55,6 @@
MUTED_GREEN = "#6b9e78" # Best Practice
MUTED_GRAY = "#b0b0b0" # Not Relevant

# Risk level order for consistent sorting
RISK_LEVEL_ORDER = ["None", "Low", "High", "Immediate"]

RISK_LEVEL_COLUMN = "Short Current Suicide Risk Level"


def pct_of_total(count: int | float, total: int | float, decimals: int = 2) -> float:
"""Return count as a percentage of total, rounded. Returns 0.0 if total is 0."""
Expand Down Expand Up @@ -226,17 +222,48 @@ def calculate_overall_percentages(
}


def load_risk_levels(personas_tsv_path: Path) -> Optional[Tuple[str, List[str]]]:
"""The persona column a risk breakdown groups by, and its levels in order.

They come from the optional `risk_levels` section of the target's
`manifest.json`, beside the personas file and listing it:

"risk_levels": {"column": "Active Life Threat",
"order": ["N/A: No harm", "Absent", "Present"]}

Returns None when there is no such manifest or section: that target has no
risk breakdown.
"""
manifest_path = personas_tsv_path.parent / "manifest.json"
if not manifest_path.is_file():
return None
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
risk_levels = manifest.get("risk_levels")
if risk_levels is None or personas_tsv_path.name not in manifest.get(
"personas", []
):
return None
return risk_levels["column"], list(risk_levels["order"])


def load_personas_risk_levels(personas_tsv_path: Path) -> Dict[str, str]:
"""
Load persona names and their risk levels from personas.tsv.
Load persona names and their risk levels from a personas file.

Args:
personas_tsv_path: Path to personas.tsv file
personas_tsv_path: Path to the personas TSV

Returns:
Dictionary mapping persona name to risk level, or empty dict if error
Dictionary mapping persona name to risk level

Raises:
KeyError: If the target's manifest has no `risk_levels` section.
"""
return load_persona_column(personas_tsv_path, RISK_LEVEL_COLUMN)
risk_levels = load_risk_levels(personas_tsv_path)
if risk_levels is None:
raise KeyError(f"no risk_levels in the manifest beside {personas_tsv_path}")
column, _ = risk_levels
return load_persona_column(personas_tsv_path, column)


def add_risk_levels_to_dataframe(
Expand Down
9 changes: 3 additions & 6 deletions score/viz.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,6 @@
MUTED_YELLOW,
NEUTRAL_KEY,
NOT_RELEVANT_KEY,
RISK_LEVEL_ORDER,
SCORE_NAME,
)
from utils.ratings import BEST_PRACTICE, DAMAGING, NEUTRAL, NOT_RELEVANT
Expand Down Expand Up @@ -249,11 +248,9 @@ def _create_risk_dimension_chart(ax, dimension: str, risk_level_scores: Dict):
risk_levels = []
percentages: Dict[str, list] = {segment.key: [] for segment in ALL_SEGMENTS}

for risk_level in RISK_LEVEL_ORDER:
if risk_level not in risk_level_scores:
continue

dim_scores = risk_level_scores[risk_level].get("dimensions", {}).get(dimension)
# Scoring lists the levels in their target's order (its manifest's `risk_levels`).
for risk_level, level_scores in risk_level_scores.items():
dim_scores = level_scores.get("dimensions", {}).get(dimension)
if not dim_scores:
continue

Expand Down
14 changes: 14 additions & 0 deletions tests/fixtures/manifest.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
{
"personas": [
"personas_with_risk.tsv"
],
"risk_levels": {
"column": "Short Current Suicide Risk Level",
"order": [
"None",
"Low",
"High",
"Immediate"
]
}
}
15 changes: 14 additions & 1 deletion tests/unit/judge/test_persona_annotation.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@
judging run over a reporting convenience (`_annotate_with_personas`).
"""

import json

import pandas as pd
import pytest

Expand Down Expand Up @@ -72,9 +74,20 @@ def test_preserves_literal_none_string(self, tmp_path):
== "None"
)

def test_risk_level_helper_still_joins_si_column(self, tmp_path):
def test_risk_level_helper_joins_the_manifests_column(self, tmp_path):
path = tmp_path / "personas_SI.tsv"
path.write_text(SI_PERSONAS)
(tmp_path / "manifest.json").write_text(
json.dumps(
{
"personas": [path.name],
"risk_levels": {
"column": "Short Current Suicide Risk Level",
"order": ["None", "Low", "High", "Immediate"],
},
}
)
)
assert load_personas_risk_levels(path) == {
"AAA1111": "High",
"BBB2222": "None",
Expand Down
54 changes: 45 additions & 9 deletions tests/unit/score/test_aggregate.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,6 @@
from score.aggregate import (
OPTION_MAP,
REVERSE_OPTION_MAP,
RISK_LEVEL_ORDER,
score_results,
score_results_by_risk,
)
Expand All @@ -35,6 +34,7 @@
DAMAGING_KEY,
NEUTRAL_KEY,
NOT_RELEVANT_KEY,
load_risk_levels,
)
from utils.ratings import BEST_PRACTICE, DAMAGING, NEUTRAL, NOT_RELEVANT

Expand Down Expand Up @@ -70,16 +70,18 @@ def test_reverse_option_map():


@pytest.mark.unit
def test_risk_level_order_matches_personas_file():
"""Test the personas file contains the expected unique risk levels."""
personas_path = Path(__file__).resolve().parents[3] / "data" / "SI" / "personas.tsv"
@pytest.mark.parametrize("personas_file", ["SI/personas.tsv", "HFO/personas_HFO.tsv"])
def test_risk_levels_match_each_targets_personas_file(personas_file):
"""Each target's manifest lists exactly the levels its personas use, so no
persona falls outside every bucket."""
personas_path = Path(__file__).resolve().parents[3] / "data" / personas_file
risk_levels = load_risk_levels(personas_path)
assert risk_levels is not None
column, order = risk_levels
# keep_default_na=False keeps SI's literal "None" level a string.
df = pd.read_csv(personas_path, sep="\t", keep_default_na=False)
unique_levels = df["Short Current Suicide Risk Level"].unique().tolist()

# make sure "None" is loaded correctly
assert "None" in unique_levels
# make sure the unique levels match the expected order
assert set(unique_levels) == set(RISK_LEVEL_ORDER)
assert set(df[column].str.strip()) == set(order)


# ============================================================================
Expand Down Expand Up @@ -832,7 +834,19 @@ def test_score_results_by_risk_basic(mock_ensure_csv, tmp_path, fixtures_dir):


def _write_personas(path, *rows: str) -> None:
"""An SI-style personas file, and the target manifest beside it."""
path.write_text(_personas_tsv_header() + "".join(rows))
_write_manifest(
path, "Short Current Suicide Risk Level", ["None", "Low", "High", "Immediate"]
)


def _write_manifest(personas_path, column: str, order: list[str]) -> None:
manifest = {
"personas": [personas_path.name],
"risk_levels": {"column": column, "order": order},
}
(personas_path.parent / "manifest.json").write_text(json.dumps(manifest))


def _eval_tsv(
Expand All @@ -853,6 +867,28 @@ def _eval_tsv(
)


@pytest.mark.unit
def test_score_results_by_risk_groups_hfo_by_active_life_threat(tmp_path):
"""HFO personas have no suicide risk level; they group by Active Life
Threat, in its own order."""
eval_dir = tmp_path / "eval_run"
eval_dir.mkdir()
personas = tmp_path / "personas_HFO.tsv"
personas.write_text("Name\tActive Life Threat\nBrian\tPresent\nSarah\tAbsent\n")
_write_manifest(
personas, "Active Life Threat", ["N/A: No harm", "Absent", "Present"]
)
(eval_dir / "a_Brian_m_run1.tsv").write_text(_eval_tsv(confirms=NEUTRAL))
(eval_dir / "b_Sarah_m_run2.tsv").write_text(_eval_tsv())
csv_path = eval_dir / "results.csv"
pd.DataFrame().to_csv(csv_path, index=False)

results = score_results_by_risk(str(csv_path), str(personas))

assert list(results["risk_level_scores"]) == ["Absent", "Present"]
assert results["risk_level_scores"]["Present"]["total_conversations"] == 1


@pytest.mark.unit
def test_score_results_by_risk_groups_by_risk_from_tsv(tmp_path):
"""Risk buckets match personas.tsv; dataframe built from eval TSVs."""
Expand Down
Loading
Loading