Add --n-per-source for stratified sampling + SKILL.md clarity

The validator's --n flag pools all scenario sources and samples N total, so large
sources dominate. For narrow axes this means most sampled scenarios don't afford
the axis. Add --n-per-source: takes N from EACH family (stratified, even sampling).

SKILL.md updates:
- step 5: mention --n-per-source and explain pooled vs stratified
- step 6: add 'test more ranked scenarios (--n-per-source 50+, more sources) and/or
  try stronger templates (system-prompt, red-team, jailbreak-style)' when 0 strict pass
- commands: use --n-per-source in dry-run and live validation examples

This fixes the root cause of the v1 honesty/credulity fumble: 50 pooled scenarios
gave 0 strict pass, and the doc didn't make it obvious that --n was pooled or that
the fix is stratified sampling with more scenarios.
This commit is contained in:
wassname
2026-07-05 21:51:28 +08:00
parent 48e5596730
commit bbfc812f45
4 changed files with 67 additions and 13 deletions
+23 -4
View File
@@ -411,9 +411,25 @@ def _rows_for_family(family: str) -> list[dict]:
return [dict(r) for r in BUILTIN_SCENARIOS[family]]
def _select_rows(families: str, n: int, seed: int) -> list[dict]:
def _select_rows(families: str, n: int, seed: int, n_per_source: int | None = None) -> list[dict]:
rng = random.Random(seed)
rows: list[dict] = []
if n_per_source is not None:
# stratified: take n_per_source from each family (even sampling, not pooled)
rows: list[dict] = []
for family in [f.strip() for f in families.split(",") if f.strip()]:
fam_rows = [{**r, "selected_family": family} for r in _rows_for_family(family)]
rng.shuffle(fam_rows)
if len(fam_rows) < n_per_source:
raise ValueError(
f"family {family!r} has only {len(fam_rows)} rows but --n-per-source={n_per_source}"
)
rows.extend(fam_rows[:n_per_source])
if not rows:
raise ValueError("selected zero scenario rows")
rng.shuffle(rows)
return rows
# pooled (legacy): shuffle all families together, take n total
rows = []
for family in [f.strip() for f in families.split(",") if f.strip()]:
rows.extend({**r, "selected_family": family} for r in _rows_for_family(family))
if not rows:
@@ -1387,7 +1403,7 @@ async def amain(args) -> None:
load_dotenv(ROOT / ".env")
axes = _select_axes(args.axes)
templates = _select_templates(args.templates)
rows = _select_rows(args.family, args.n, args.seed)
rows = _select_rows(args.family, args.n, args.seed, args.n_per_source)
axis_judge_models = tuple(
model.strip() for model in args.axis_judge_models.split(",") if model.strip()
)
@@ -1568,7 +1584,10 @@ def main() -> None:
help="generation temperature; default 0 to avoid sampling-diff confounds")
ap.add_argument("--family", default="character",
help="comma-separated scenario families; default avoids sycophancy")
ap.add_argument("--n", type=int, default=6, help="number of scenario prompts")
ap.add_argument("--n", type=int, default=6, help="number of scenario prompts (pooled across all families)")
ap.add_argument("--n-per-source", type=int, default=None,
help="stratified sampling: take this many scenarios from EACH family (overrides --n). "
"Use this so each source contributes equally, not proportional to its size.")
ap.add_argument("--axes", default=str(ROOT / "data/personas/persona_pairs_pilot_two.jsonl"),
help="persona-pair JSONL path")
ap.add_argument("--templates", default=str(ROOT / "data/templates/template_catalog.yaml"),