Skip to content
Open
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
12 changes: 7 additions & 5 deletions architecture/probes.md
Original file line number Diff line number Diff line change
Expand Up @@ -168,11 +168,13 @@ the turn had no side effects.
## Assets

A probe's content lives at `engine/assets/<label>/`, or, for a probe kept in
its own folder, at `engine/probes/<label>/assets/<label>/`, resolved through
`probe_assets_dir()`. A run fails pre-flight, before any model call, if a
selected probe's bank is missing or, for the probes whose banks are split by
locale, doesn't load for a run locale (a finance bank without tasks, for
instance). Those probes declare a `locale_bank_check` on the class, which the
its own folder, at `engine/probes/<folder>/assets/<label>/`, where the folder
is named after the probe or, as `health_disclosure` is, shared by several. It
is resolved through `probe_assets_dir()`. A run fails pre-flight, before any
model call, if a selected probe's bank is missing or, for the probes whose banks
are split by locale, doesn't load for a run locale (a finance bank without
tasks, for instance). Those probes declare a `locale_bank_check` on the class,
which the
pre-flight calls on the bank the class's `bank_locale` names. The refusal names
the probe, the locale and the locales that can run, rather than failing from
inside a loader mid-run. `simulate` and its `--dry-run`, `--materialize-inputs`,
Expand Down
13 changes: 7 additions & 6 deletions docs/engine/AUTHORING_A_PROBE.md
Original file line number Diff line number Diff line change
Expand Up @@ -256,13 +256,14 @@ class MyProbe(BaseProbe):

It must be absolute: `@register_probe` refuses a relative path, which would
resolve against the working directory. In this repository the banks are then
at `src/usersim/engine/probes/<label>/assets/<label>/`, as for
at `src/usersim/engine/probes/<folder>/assets/<label>/`, as for
`general_educational`, `general_open_ended`, the three `sov_ai_*` probes,
`safety_chat_pressure`, `safety_agentic`, `identity_disclosure` and
`financial_services`, which keep their bank loader (`bank.py`, `taxonomy.py`
for `sov_ai_dynamic`, `spec.py` for `identity_disclosure`) and their scorer
(`scorer.py`) in that folder too, and their tests in `tests/probes/<label>/`.
A built-in that leaves
`safety_chat_pressure`, `safety_agentic`, `identity_disclosure`,
`financial_services` and the four `health_*` probes, which share the
`health_disclosure/` folder. They keep their bank loader (`bank.py`,
`taxonomy.py` for `sov_ai_dynamic`, `spec.py` for `identity_disclosure`) and
their scorer (`scorer.py`) in that folder too, and their tests in
`tests/probes/<folder>/`. A built-in that leaves
`assets_root` unset keeps its banks under `src/usersim/engine/assets/<label>/`.

Its bank loader reads under `MyProbe.assets_dir()`, the probe's own folder,
Expand Down
9 changes: 6 additions & 3 deletions docs/engine/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -132,7 +132,7 @@ different questions of the model.
| Probing / dynamic-tier taxonomy | `core/probing_taxonomy.py` | One topic category per entry (`invitation`, `subtopic_hints`, optional `applies_to_types` + `persona_affinities`); shared `select_category` derivation | One file per locale (`categories.yaml` for sov_ai_dynamic; `dynamic.yaml` for financial_services) | `sov_ai_dynamic`, `financial_services` |
| Financial-services bank | `probes/financial_services/bank.py` (+ `tasks.py`, `embeddings.py`) | Per-institution corpus + tools + tasks + embeddings; `Institution` / `FinanceBank` typed views; sim-time gold derivation | One dir tree per locale (`<type>/<institution>/`) | `financial_services` |
| Query bank | `probes/sov_ai_multilingual_parity/bank.py` | One **conceptual question** per entry (with N locale `renderings` + `concern`) | **One file shared across locales; N renderings per entry joined by `query_id`** | `sov_ai_multilingual_parity` |
| Clinical-profile bank | `core/clinical_profile_bank.py` | One clinical profile per entry (versioned concealment ground truth) | One bank per client label | the four `health_disclosure` clients |
| Clinical-profile bank | `probes/health_disclosure/bank.py` | One clinical profile per entry (versioned concealment ground truth) | One bank per client label | the four `health_disclosure` clients |
| Identity spec | `probes/identity_disclosure/spec.py` | Developers, ordered expected-identity rules, competitors, tactics and strategies; a spec layer can extend another | One file shared across locales; localized text per tactic | `identity_disclosure` |

The query bank's cross-locale shape is what makes cross-locale comparison
Expand Down Expand Up @@ -608,7 +608,6 @@ The tables describe the main files.
| `test_probe_invariants.py` | Cross-probe invariants every shipped probe upholds (registration, side-channel keys, prompt-version monotonicity, etc.) |
| `test_tool_calling.py` | Tool-calling probe: tool sampling, gate check |
| `test_verifiers.py` | Tool-call JSON-schema validator |
| `test_health_disclosure.py` | `health_disclosure` family: the Guard's pacing gates, the `commit_move` tool schema, move extraction, and the per-client config (topics, risk, archetype, prompts, axes) |

**`tests/engine/evaluator/`: judges, scorers, registry**

Expand All @@ -621,7 +620,6 @@ The tables describe the main files.
| `test_evaluator_scorers.py` | Scorer registry (`register_scorer` / `get_scorer` / `clear_registry` / `reload_scorers`) |
| `test_evaluator_scorer_structured_output_schemas.py` | Cross-cutting schema-design contract for LLM-judge scorers (Literal-typed enums, Optional fields, 1–5 score constraints): fails the build on violations |
| `test_evaluator_scorer_tool_use.py` | 8-axis tool-calling trajectory judge |
| `test_evaluator_scorer_health_disclosure.py` | `health_disclosure_concealment` scorer: concealment metrics derived from synthetic rows, with the LLM audit stage patched where exercised and the deterministic path everywhere else |
| `test_evaluator_scorer_language_compliance.py` | Deterministic (no-LLM) scorer: per-trajectory `language.{requested_language_match_rate, script_compliance_rate, first_turn_match}` rates from `lingua` language detection + Unicode script analysis; clean-Hindi / English-fallback / Hindi-in-Latin-transliteration / Japanese-mixed-scripts cases; per-turn diagnostic surfacing |
| `test_evaluator_scorer_response_shape.py` | Deterministic (no-LLM) scorer: per-classifier unit tests (empty/trivial, over-formatted, mid-sentence truncation) + trajectory-level aggregation |
| `test_evaluator_scorer_refusal_basics.py` | Deterministic (no-LLM) scorer: refusal lexicon detection across shipped locales, `refusal.in_wrong_language_rate` (English-refusal-in-non-English-locale) composite, canned-AI-phrase detection, status-proposal gating semantics |
Expand Down Expand Up @@ -654,3 +652,8 @@ The tables describe the main files.
| `identity_disclosure/test_contradictions.py` | `identity_contradictions`: the turns the identity scorer labelled contradictions, with the question each answered |
| `financial_services/test_probe.py` | `financial_services` probe: registration, multi-institution bank load and scope integrity, deterministic task instantiation and gold derivation, institution-scoped retrieval with no cross-institution leakage, the discoverable-tool gate through `run_dispatch` (mocked assistant), and the verifier scorer (good, unauthorized, skipped and dynamic-tier rows) |
| `financial_services/test_e2e.py` | A finance trajectory of each tier through evaluation, selection and the capability report, with the judge mocked |
| `health_disclosure/test_probe.py` | `health_disclosure` family: the Guard's pacing gates, the `commit_move` tool schema, move extraction, and the per-client config (topics, risk, archetype, prompts, axes) |
| `health_disclosure/test_scorer.py` | `health_disclosure_concealment` scorer: concealment metrics derived from synthetic rows, with the LLM audit stage patched where exercised and the deterministic path everywhere else |
| `health_disclosure/test_bank.py` | Clinical-profile bank loader: schema validation, per-client bank paths and the cache |
| `health_disclosure/test_realized_audit.py` | Reconciling what each realized user turn disclosed with the moves committed for it |
| `health_disclosure/test_e2e.py` | The probe's real columns through the store to the concealment scorer |
33 changes: 22 additions & 11 deletions src/usersim/engine/core/_assets.py
Original file line number Diff line number Diff line change
Expand Up @@ -127,25 +127,36 @@ def declared_assets_roots() -> dict[str, Path]:
}


def _builtin_probe_root(probe: str) -> Path:
"""``usersim/engine/probes/<probe>/assets/``, where a built-in kept in its own folder holds ``<probe>/``."""
return Path(str(resources.files("usersim.engine") / "probes" / probe / "assets"))
def _builtin_probes_dir() -> Path:
"""``usersim/engine/probes/``, which holds the built-in probe packages."""
return Path(str(resources.files("usersim.engine") / "probes"))


def _builtin_probe_root(probe: str) -> Path | None:
"""The ``assets/`` folder of the built-in package that holds ``<probe>/``, if one does.

A package is usually named after its probe. A package that holds several probes
keeps each one's banks in its ``assets/<probe>/``.
"""
packages = _builtin_probes_dir()
own = packages / probe / "assets"
if (own / probe).is_dir():
return own
return next((root for root in sorted(packages.glob("*/assets")) if (root / probe).is_dir()), None)


def _declared_root(probe: str) -> Path | None:
"""The root ``probe``'s class declares, found before its module is imported if it is a built-in.

A built-in kept in its own folder declares ``usersim/engine/probes/<probe>/assets/``.
Until its class is registered, that folder stands in for the declaration when it
holds ``probe``, so a lookup made while the probe's module loads, such as a prompt
read at import time, or by a scorer in a process that never imports the probe,
reaches the probe's assets.
A built-in kept in its own folder declares its package's ``assets/`` folder under
``usersim/engine/probes/``. Until its class is registered, the package folder that
holds ``probe`` stands in for the declaration, so a lookup made while the probe's
module loads, such as a prompt read at import time, or by a scorer in a process
that never imports the probe, reaches the probe's assets.
"""
declared = declared_assets_roots().get(probe)
if declared is None and probe not in probes._PROBE_REGISTRY:
builtin = _builtin_probe_root(probe)
if (builtin / probe).is_dir():
return builtin
return _builtin_probe_root(probe)
return declared


Expand Down
2 changes: 1 addition & 1 deletion src/usersim/engine/evaluator/scorers/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -323,7 +323,7 @@ def reload_scorers(scorer_module_paths: Iterable[str]) -> None:
"usersim.engine.evaluator.scorers.language_compliance",
"usersim.engine.evaluator.scorers.response_shape",
"usersim.engine.evaluator.scorers.refusal_basics",
"usersim.engine.evaluator.scorers.health_disclosure",
"usersim.engine.probes.health_disclosure.scorer",
"usersim.engine.probes.identity_disclosure.scorer",
)

Expand Down
17 changes: 8 additions & 9 deletions src/usersim/engine/probes/health_disclosure/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,15 +19,15 @@ content once the assistant (the system under test) earns it.
| `moves.py` | Generic **move-space + Guard** (deterministic, no LLM). Shared verbatim by all clients. |
| `move_runtime.py` | Glue: profile → env, and the `commit_move` tool call + Guard veto loop. Client-agnostic. |
| `mixin.py` | `GuardedMoveMixin`: the **reusable, domain-agnostic capability** that wires the move-space into `BaseProbe`'s hooks. Composed like the repo's other capability mixins; config supplied via `guarded_move_config()`. |
| `prompts.py` | The four user-agent prompts, a shared gate-prompt builder, the DECIDE scaffold, and stand-in assistant prompts. Every shippable scaffold is a `LocalePromptPack` (all-locales-or-fail); see **Locale** below. The realized-turn-audit scaffold is **not** here: it sits with the auditor in `core/realized_audit.py`, since the audit runs in the evaluator. |
| `prompts.py` | The four user-agent prompts, a shared gate-prompt builder, the DECIDE scaffold, and stand-in assistant prompts. Every shippable scaffold is a `LocalePromptPack` (all-locales-or-fail); see **Locale** below. The realized-turn-audit scaffold is **not** here: it sits with the auditor in `probes/health_disclosure/realized_audit.py`, since the audit runs in the evaluator. |
| `clients.py` | **Per-client config** (topics, risk meaning, roles, proposal framing). Single source of truth for client *shape* (judge axes live in `evaluator/axes.py`). |
| `base.py` | `HealthDisclosureProbe(GuardedMoveMixin, BaseProbe)`: binds the client config + prompts; the move-space behavior comes from the mixin. |
| `therapy.py` / `triage.py` / `decision_support.py` / `general.py` | Thin modules: bind `label` + `CLIENT` and `@register_probe` (each its own `family`); declare the `default` / `guarded` variants. |

Adding a client = one entry in `CLIENTS` + one thin module (copy `triage.py`)
+ its judge axes in `evaluator/axes.py::PROBE_SCORES` + a bundled bank under
`assets/<label>/sample.yaml` (with its label in
`clinical_profile_bank.CLIENT_PROBE_LABELS`) + its module path in
`bank.CLIENT_PROBE_LABELS`) + its module path in
`core/probes.py::BUILTIN_PROBE_MODULES`.

> **Reusing the move-space elsewhere.** `GuardedMoveMixin` hardcodes nothing
Expand Down Expand Up @@ -56,9 +56,8 @@ Provided by `GuardedMoveMixin`. When enabled, each follow-up turn runs
while its hardest step happened upstream in a model. Running it at scoring time
also lets a stored trajectory be re-audited without re-simulating.

The shared machinery lives in `core/realized_audit.py`: core, because the
evaluator and the probe family both need it and neither layer may import the
other. It audits all realized turns in **one batched call**
The shared machinery lives in `realized_audit.py`, beside the probes and the
scorer, which both use it. It audits all realized turns in **one batched call**
(`verify_realized_transcript`) using an **independent auditor** (`judge_model` if
wired, else `evaluator_model`, else `user_model`: see `resolve_audit_model`;
overridable via `USERSIM_AUDIT_MODEL`), grading each topic three ways: `full` / `partial` /
Expand Down Expand Up @@ -111,7 +110,7 @@ Provided by `GuardedMoveMixin`. When enabled, each follow-up turn runs
- Heavy cognitions (`hopelessness`/`worthlessness`) are paced like risk for non-`open` users.

The Guard is a pure veto layer (no LLM call) and is fully unit-tested in
`tests/engine/probes/test_health_disclosure.py`.
`tests/probes/health_disclosure/test_probe.py`.

## Enabling the move-space (per trajectory)
The move-space is a **`probe_variant`**, not a process-global flag, so a single
Expand Down Expand Up @@ -174,7 +173,7 @@ user, so the family is safe to register and run by default.
## Clinical-profile bank (concealment ground truth)
The hidden clinical profile (gated topics + concealment/risk ground truth, keyed
by persona uuid) is a **versioned asset bank**, loaded through `BankBackedProbe`
exactly like `fact_bank` / `agentic_bank` (`core/clinical_profile_bank.py`). This
exactly like the `sov_ai_facts` and `safety_agentic` banks (`bank.py` here). This
buys the family `provenance.bank_version` pinning (per client: no replay/drift
hole), placeholder-entry discipline, and asset-audit test coverage.

Expand Down Expand Up @@ -208,7 +207,7 @@ What the free-form utterances actually did (realized **behavior**) is not a
trajectory column: the concealment scorer audits it at eval time, below.

### Concealment scorer (opt-in)
`evaluator/scorers/health_disclosure.py` scores in two stages:
`probes/health_disclosure/scorer.py` scores in two stages:

1. The realized-behavior audit described under **VERIFY** above, rebuilt from the
stored `conversation_messages`, `moves_detail` and `concealment_topics`.
Expand Down Expand Up @@ -247,7 +246,7 @@ for comparable-strata rollups. Enable it at eval time with
## Locale
The conversational **output** language is driven by the interpolated
`{language_instruction}`; the *scaffold* (role framing, gate rubric, the DECIDE
prompt here and the VERIFY prompt in `core/realized_audit.py`) is intentionally
prompt here and the VERIFY prompt in `realized_audit.py`) is intentionally
English. Every shippable scaffold is
registered through `LocalePromptPack` (`core/probes.py`) under every shipped
locale, so it flows through the same **all-locales-or-fail** validation as the
Expand Down
11 changes: 7 additions & 4 deletions src/usersim/engine/probes/health_disclosure/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,21 +19,22 @@

from __future__ import annotations

from pathlib import Path
from typing import Any, Mapping

from usersim.engine.core.behavioral import (
format_behavioral_profile_for_prompt,
format_disclosure_instructions,
format_interaction_style_instructions,
)
from usersim.engine.core.clinical_profile_bank import (
DEFAULT_PROFILE_ID,
ClinicalProfile,
)
from usersim.engine.core.outcomes import WarningKind
from usersim.engine.core.persona import format_persona_for_prompt
from usersim.engine.core.probes import BankBackedProbe
from usersim.engine.core.simulation import language_instruction
from usersim.engine.probes.health_disclosure.bank import (
DEFAULT_PROFILE_ID,
ClinicalProfile,
)
from usersim.engine.probes.health_disclosure.mixin import (
GuardedMoveMixin,
env_override,
Expand All @@ -58,6 +59,8 @@ class HealthDisclosureProbe(GuardedMoveMixin, BankBackedProbe):
(one bank per client; see therapy.py / triage.py / decision_support.py).
"""

#: The four clients' banks, each in its own ``<label>/`` folder.
assets_root = Path(__file__).parent / "assets"
#: Set by each client subclass to a config dict from ``clients.py``.
CLIENT: dict = {}
#: Local-dev override honoured by ``GuardedMoveMixin.guarded_moves_enabled``.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,10 @@

from __future__ import annotations

from usersim.engine.core.clinical_profile_bank import (
from usersim.engine.core.probes import register_probe
from usersim.engine.probes.health_disclosure.bank import (
load_clinical_profile_bank_for_client,
)
from usersim.engine.core.probes import register_probe
from usersim.engine.probes.health_disclosure.base import HealthDisclosureProbe
from usersim.engine.probes.health_disclosure.clients import CLIENTS

Expand Down
4 changes: 2 additions & 2 deletions src/usersim/engine/probes/health_disclosure/general.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,10 @@

from __future__ import annotations

from usersim.engine.core.clinical_profile_bank import (
from usersim.engine.core.probes import register_probe
from usersim.engine.probes.health_disclosure.bank import (
load_clinical_profile_bank_for_client,
)
from usersim.engine.core.probes import register_probe
from usersim.engine.probes.health_disclosure.base import HealthDisclosureProbe
from usersim.engine.probes.health_disclosure.clients import CLIENTS

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@
comes from the client config in ``clients.py``. The full hidden clinical profile
(concealment ground-truth + gated topics, keyed by persona uuid) is supplied by
the host probe from the versioned clinical-profile bank (``BankBackedProbe`` →
``self._task``; see ``core/clinical_profile_bank.py``), passed into ``build_env``.
``self._task``; see ``probes/health_disclosure/bank.py``), passed into ``build_env``.
Absent a profile, we fall back to generic topics so the probe still runs.
"""

Expand Down
4 changes: 2 additions & 2 deletions src/usersim/engine/probes/health_disclosure/moves.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,10 +19,10 @@

from dataclasses import dataclass, field

from usersim.engine.core.realized_audit import (
from usersim.engine.probes.health_disclosure.realized_audit import (
CONCEALING_MOVES as _CONCEALING_MOVES,
)
from usersim.engine.core.realized_audit import (
from usersim.engine.probes.health_disclosure.realized_audit import (
DISCLOSING_MOVES as _DISCLOSING_MOVES,
)

Expand Down
5 changes: 2 additions & 3 deletions src/usersim/engine/probes/health_disclosure/prompts.py
Original file line number Diff line number Diff line change
Expand Up @@ -235,9 +235,8 @@ def _english_scaffold_pack(label: str, template: str) -> LocalePromptPack:
PROPOSAL_SYSTEM_PACK = _english_scaffold_pack("health_disclosure.proposal_system", PROPOSAL_SYSTEM_TEMPLATE)


# The realized-turn verification prompt lives in ``core/realized_audit.py``
# alongside the auditor: the audit runs in the evaluator, and core may not
# import a probe.
# The realized-turn verification prompt lives in ``realized_audit.py`` beside this
# module, alongside the auditor, which the scorer runs at scoring time.


# ===========================================================================
Expand Down
Loading
Loading