Skip to content

MindEval 2 on master: multi-session simulator, and --judge_version for mindeval1/mindeval2 - #1

Merged
johndmendonca merged 1 commit into
masterfrom
mindeval2-port
Oct 3, 2026
Merged

johndmendonca merged 1 commit into
masterfrom
mindeval2-port

Conversation

@johndmendonca

@johndmendonca johndmendonca commented Sep 29, 2026 •

Copy link
Copy Markdown
Contributor

Summary

  • master becomes MindEval 2: MindEval2's multi-session patient simulator (50 synthetic members, 3 sessions each, with a memory log carried between sessions) is ported here as the mindeval package.
  • MindEval 1 is kept as it was on the new legacy branch, a copy of 1c17f9e.
  • Style follows mind-eval:
    • scripts run by path with jsonargparse and snake_case flags: python mindeval/scripts/generate_interactions.py and python mindeval/scripts/generate_judgments.py;
    • judge_prompts.py, utils.py and run_benchmark.sh keep their names;
    • the README keeps v1's sections and adds a short What's New.
  • The judge version is an argument, --judge_version:
    • mindeval2 (the default) is an MQM judge, one verdict per counselor reply. It needs the counselor system prompt from the trace. Its prompt ships in mindeval/judge_prompts.py as a system message and a user message, and MINDEVAL_JUDGE_PROMPT can replace both with one file.
    • mindeval1 is v1's five-criterion rubric. The rubric and few-shots are kept byte-for-byte. <member_details> holds the counselor's memory for that session, which is what the counselor itself saw. It gives one verdict per session and does not read the counselor system prompt.
    • Each version writes to judge/<version>/, so both can run on one run at the same time.
  • Same arcs as MindEval2 @ a18de8b: every member's schedule, time context and per-call seeds are drawn from a pinned namespace equal to MindEval2's fingerprint (7962e7caed157571), not from the config's bytes (fingerprint now 5987fd25e55199ab). A config or prompt edit can't re-draw the schedules, and the namespace is part of run.json's identity. Later-session situations do differ from MindEval2's, by design: see the Member changes below.

Judge prompt

  • Default judge: GPT-6.1 Sol. .env.example sets MINDEVAL_JUDGE_MODEL=openai/gpt-6.1-sol (azure/<deployment> on Azure), and generate_judgments.py defaults to its settings: --judge_params '{"reasoning_effort": "high"}', no temperature, --judge_max_tokens 32768. Any other model can be set in MINDEVAL_JUDGE_*.
  • In the repo now: the prompt ships in mindeval/judge_prompts.py, so nothing needs downloading.
  • Two messages: a system message holds the task, severities, rubric and answer format (MINDEVAL2_JUDGE_SYSTEM_PROMPT). It's identical on every call, so Azure/OpenAI, vLLM and Anthropic can cache it, about 90% of each call's input. The user message holds only the example: the conversation so far and the reply (MINDEVAL2_JUDGE_USER_PROMPT). A test checks that putting the example back in the middle reproduces the validated single-message prompt byte for byte, apart from "Below is part of a conversation", which now reads "The next message holds part of a conversation".
  • License: both prompt constants are CC BY-NC-SA 4.0, like the rest of the models and data; the code is Apache 2.0.

Behaviour differences from MindEval2 @ a18de8b

All of these came out of the review; everything else behaves the same, which a parity harness checks over 50 members × 3 sessions.

Member

  • Moves an archetype avoids are no longer named in its prompt: only the move names are removed. At seed 0 this changes the prompts of 14 of the 50 members.
  • Per-attempt state in speak().
  • Valid JSON in a prose commit_move.
  • An inline </think> is split off every patient call.
  • Compaction and situation evolution back off between retries.
  • Compaction keeps the newest log entries.
  • The situation writer between sessions gets the running log, so a fact from session 1 that session 2 never came back to (a corrected name, a commitment) still holds in session 3.
  • The opening message it writes, spoken verbatim, gets the next session's archetype rules, concealment and affect, so it can't disclose what the rest of the session is played to hold back. Session 1's openings come from the profiles and are unchanged.

Counselor

  • litellm-mapped params such as reasoning_effort are no longer also forced through raw.
  • A Claude turn that interleaves thinking and text goes back without its thinking.

Judge

  • The preflight only gates a directory with no verdict yet, and tries up to 3 units.
  • judge.json is never missing beside a verdict.
  • --judge_params is screened and part of the judge's identity.
  • Reads tolerate U+2028 and torn UTF-8.
  • Verdicts whose judge.json was removed are refused (exit 2), not pinned to whichever judge resumes them.
  • A torn last verdict is repaired before the next pass appends, so the next verdict isn't glued onto it.
  • summary.json reports mqm.incidents beside the mean: the share of sessions with a Critical reply, and with a Major or worse. The mean dilutes one severe reply in a long session; this doesn't.

CLI

  • --patient_params merges deeply.
  • A filesystem without locks is refused (exit 2).
  • run_benchmark.sh stops on a refused stage and quotes its path.

Test plan

  • uv sync --locked, ruff check ., pytest -q (53 passed)
  • Parity harness against MindEval2 @ a18de8b (50 members × 3 sessions, scripted fake LLM), run before c67711d: arc ids, run ids, schedules, time lines, situations and logs were identical; the only differences were the avoided-move filter and run.json.seed_namespace. Since c67711d, the evolution prompt also carries the log and the next session's conduct, so later-session situations differ by design.
  • Every reproduction from the review was rerun against HEAD, and each finding is fixed. There are mutation tests for the fixes.
  • The CI codename check passes over the tree and every commit since 1c17f9e.
  • Live runs of the port against real models:
    • 12 counselors, including Qwen3.5/3.8, Gemma 4, Muse-Glimmer-30B and Claude Opus 5;
    • a DeepSeek-V4.1-Flash patient;
    • judged with GPT-6.1 Sol, all before c67711d (sessions 2–3 of those runs used the earlier evolution prompt).

Notes for reviewers

  • License: LICENSE (Apache 2.0, SWORD Health) is new to this repo and covers the code, including the v1 rubric text in mindeval/judge_prompts.py, so the v1 maintainer should confirm this. Models and data are CC BY-NC-SA 4.0, including data/profiles.jsonl and MINDEVAL2_JUDGE_SYSTEM_PROMPT / MINDEVAL2_JUDGE_USER_PROMPT.
  • Breaking changes for the internal eval wrappers:
    • env MINDEVAL2_* → MINDEVAL_*;
    • mindeval2 run|judge → the two scripts;
    • judge/summary.json → judge/mindeval2/summary.json;
    • the judge defaults to GPT-6.1 Sol at high reasoning effort, and MINDEVAL_JUDGE_PROMPT is optional;
    • repo and commit pin → this repo.

🤖 Generated with Claude Code

https://claude.ai/code/session_012mjnStrFaThvioxBC2vB2w

@zeppombal zeppombal left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thorough pass over the port. Each inline finding was reproduced at 978ae09 with a scripted litellm, and with litellm 1.98.1's real Anthropic transformation where noted. Most of mindeval/ is MindEval2 @ a18de8b apart from renames, so inline comments on that code are marked (inherited). It's new to this repo, but a follow-up PR or an upstream fix is fine for those.

Introduced by this PR

  • 978ae09 re-seeds every member's arc, so the MindEval2 parity claims in the description no longer hold (tests/test_judge.py:268).
  • --judge_params isn't part of the judge identity (judge.py:345), and it isn't screened for api_key/api_base, which then end up in judge.json (generate_judgments.py:24).
  • mindeval1 gives the judge information the counselor didn't have, and leaves out the memory it did have (judge.py:263). Its rows also keep no answer text (judge.py:312).
  • run_benchmark.sh ignores exit codes.

Inherited, worth a follow-up

  • The preflight on batch[0] can stall a judge directory for good (judge.py:404).
  • A stale stray in Member.speak drops a clean reply, or loops without bound (member.py:283).
  • A raw reasoning_effort goes to Anthropic and gets a 400 (llm.py:106). A prose commit_move breaks a Claude patient's session (member.py:238).
  • U+2028 in verdicts.jsonl causes re-judging (judge.py:335). Traces are decoded as strict UTF-8 (judge.py:88).
  • Compaction and evolution retry with no backoff (arc.py:201). Moves an archetype avoids are still named in its prompt (member.py:400).

Smaller things (no inline comment)

  • release_locks() pops every lock in the process. In test_both_versions_run_concurrently_on_one_run_dir, mindeval1's finally releases mindeval2's lock mid-pass, so the test doesn't really exercise per-version locking: with the lock regressed to one shared judge/.lock, it still passed 5 of 15 runs. A with hold_lock(dir): that owns its handle fixes both.
  • mindeval2 now renders every pending prompt up front and keeps them all until the pass ends, where MindEval2 rendered in _judge_one. In a synthetic run (400-char turns, 10 KB template) that measured about +250 MB at default scale and about +700 MB at --max_turns 60, and --limit doesn't bound it. Hash each prompt, drop it, and render again in one().
  • Stale names: llm.py:6 says --counselor-params, and .env.example:18 points to a README section "Judging (MQM)" that is now "Run judgments".
  • Optional cleanups:
    • print_summary reads summary["replies"]/["sessions"] directly instead of via units_label, and the coverage/error code is duplicated across both judge classes.
    • requires_counselor_system_prompt and reference_sha256 exist only so judge_run can run mindeval2-only checks, which also makes render_prompt's no-system-prompt branch unreachable.
    • The CLI's Literal[...] and judge_version == "mindeval2" duplicate JUDGE_VERSION_DICT.
    • Making JudgeError a subclass of InputError would drop the translation in main.
    • Test helpers are duplicated between test_smoke.py and test_judge.py and could move to a conftest.py.
  • Minor inherited items:
    • speak() offers the todo tools with tool_choice: "auto" on every agenda turn, and all 50 profiles have a goal. The preflight and README only cover a named tool_choice, and vLLM needs --enable-auto-tool-choice for auto.
    • compact_log's [:max_entries] keeps the oldest entries, so an overshoot drops the new session's entries, [open] ones included.
    • --patient_params is merged shallowly, so any extra_body key drops chat_template_kwargs.enable_thinking.
    • Patient calls don't strip an inline …</think> the way the counselor path does (split_inline_trace).
    • hold_lock reports any OSError, e.g. ENOLCK on NFS, as "another mindeval process is running".

Comment thread tests/test_judge.py Outdated
def test_fingerprint_is_pinned():
# The config of MindEval2 a18de8be (7962e7caed157571) with the YAML comments removed: the same data,
# other bytes. A change here means config/*.yaml or prompts/*.j2 changed.
assert load_config().fingerprint == "37868bb5e406de0d"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The config data is unchanged, but the benchmark isn't. The fingerprint hashes raw bytes (config.py:229) and is also seed material: arc_id_of (arc.py:44) and _run_id (session.py:181) include it, and every schedule draw and per-call seed derives from those. So 978ae09 re-rolls every member's arc under the same --seed 0. Compared with MindEval2 @ a18de8b on the same profiles:

  • inter-session gaps differ for 50/50 members, the anchor hour for 50/50, the weekday for 43/50, the beats for 49/50 and the concealment schedule for 16/50; every episode seed differs;
  • 48/50 session-1 member system prompts differ in the time line, e.g. m000119 goes from It is Friday evening. to It is Sunday evening..

So "MindEval2's behaviour is unchanged", "the member system prompts of all 50 profiles render identically" and the parity check in the description don't hold at the head commit. They look like they were checked before this commit, or rendered without time_context. If MindEval2 numbers need to carry over, I'd drop this commit; stale names in YAML comments are harmless. Otherwise, please say in the description that MindEval2 runs aren't reproducible from this repo. Separately, hashing the parsed config, or keeping the fingerprint out of the seed material, would stop a comment edit from re-rolling the benchmark.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We kept the comment-free YAML but took the fingerprint out of the seed material: since e694a58, arc_id_of and _run_id draw from a pinned namespace equal to MindEval2's own fingerprint (7962e7caed157571), so schedules, time lines and per-call seeds match MindEval2 @ a18de8b again over all 50 profiles x 3 sessions, and a comment edit can't re-draw anything. 701ae29 adds that namespace to run.json's identity, so a run directory started at 978ae09 is refused on resume instead of mixing two drawings. The one prompt difference left is the deliberate avoided-move fix from the member.py:400 thread.

Comment thread mindeval/judge.py

# One directory holds one judge. The identity is pinned by the first verdict that succeeds, so a
# mistyped model or a bad template that never produced a verdict can simply be corrected.
identity = {"model": spec.model, "prompt_sha256": judge.prompt_sha256, "temperature": spec.temperature,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

--judge_params is new in this port (MindEval2's judge had no params), but it isn't part of the identity. A later pass with different params is accepted into the same verdict set, and judge.json (L407-409) is rewritten with only the latest params. For example, pass 1 with --judge_params '{"reasoning_effort": "low"}' --limit 100, then pass 2 with '{"reasoning_effort": "high"}': exit 0, summary.json averages both configs, and judge.json reports high for all of them. That contradicts the docstring's "one directory never mixes two judges". run.json's IDENTITY already pins counselor.params and patient.params, so adding "params": spec.params here would be consistent, once secrets are kept out of params (see generate_judgments.py:24).

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in cf39649: the (now screened) params are part of the judge's identity, so your second pass is refused with exit 2 instead of being averaged in. 4830657 also writes judge.json before the first verdict can land, so a directory can't end up with verdicts but no pinned identity.

Comment thread mindeval/scripts/generate_judgments.py Outdated


def main(output_dir: Path, judge_version: Literal["mindeval1", "mindeval2"] = "mindeval2",
judge_temperature: float | None = 1.0, judge_max_tokens: int = 16384, judge_params: dict = {},

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

judge_params doesn't get the reserved-key check that generate_interactions.py:195-198 applies to --counselor_params/--patient_params; MindEval2 did that check in _params at parse time. Because judge_run writes "params": spec.params to judge.json:

  • --judge_params '{"api_key": "sk-…"}' leaves the key in plain text in judge/<version>/judge.json, against the README's "keys are never written to the outputs". The smoke test checks this only for the env-var path.
  • An api_base in params is used whenever MINDEVAL_JUDGE_API_BASE is unset, while judge.json records "api_base": null.

v1 users will do exactly this, since v1's --judge_model_api_params carried model and api_base. I'd suggest one shared check_params(flag, params) in utils.py for all three flags, and judge_params: dict | None = None like the other two; today --judge_params null is rejected while --counselor_params null is accepted.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in cf39649 as suggested: one check_params(flag, params) in utils.py rejects model, messages, api_base and api_key for all three --*_params flags, and judge_params is dict | None = None, so --judge_params null is accepted like the other two. A test checks that an api_key is refused and never written to judge.json.

Comment thread mindeval/judge.py Outdated
# rebuilding from `turns` instead keeps v1's mapping (member "user", counselor "assistant").
messages = [{"role": "user" if t["speaker"] == "member" else "assistant", "content": t["text"]}
for t in turns]
prompt = self.template.substitute(formulation=session["formulation"],

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A question about what mindeval1 shows the judge. In v1, <member_details> held the same intake fields the clinician got as <member_context> (v1 prompts.py), so judge and clinician had the same information, and the few-shots reward using it ("your profile notes you've been averaging 5 or fewer hours…"). Here that changes:

  • <member_details> is the member's private formulation (backstory, and what they came in withholding), which the counselor never sees.
  • In sessions 2 and later the counselor worked from its # Memory log, which this judge never sees. So a counselor that correctly recalls session 1 can look like criterion 5's "hallucinate details not present in the conversation". That's about two thirds of sessions.

Would it be closer to v1 to put what the counselor had, i.e. the rendered memory from counselor_system_prompt, in <member_details>, with or without the formulation? Whichever way it goes, the choice deserves a line in the README; otherwise mindeval1 scores on v2 runs are hard to interpret.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We went with what the counselor had, without the formulation: since 107f1ba, <member_details> is exactly the text the counselor received in {{ memory }} for that session (the "no earlier sessions" line in session 1, the compacted log after that), and mindeval1 no longer reads the formulation at all. The rubric and few-shots are unchanged, 04cefd7 pins the memory per member and session in the tests, and the README says so (178581c).

Comment thread mindeval/judge.py Outdated
if meta.get("finish_reason") == "length":
# A cut-off answer is not a verdict: whatever JSON it holds may be a fragment of the reasoning.
raise JudgeError(f"the judge hit its token limit ({spec.max_tokens}) before finishing its answer")
return {**judge.parse(text), "usage": meta.get("usage")}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

v1 kept unparsed_judgment and thinking_trace for every judgment. A mindeval1 row here keeps only the seven numbers and usage; mindeval2 at least has justification. A failed parse leaves only the exception message in _error. So a mis-parse can't be audited or re-parsed without paying for a new judgment; for example, the regex will take the 2 from commentary like Clinical Accuracy & Competence: 2 issues stand out… written before the ratings. Could this keep the answer text (truncated) and meta.get("reasoning_content") on the row, and put a snippet of the unparseable text into _error?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

65cd3dc keeps the answer text and reasoning_content on every mindeval1 row and puts a snippet of the unparseable answer in _error, and becc156 splits an inline </think> trace off first, so the kept text is the verdict rather than the start of the reasoning. parse_judge_scores now takes each criterion's last match, so the Clinical Accuracy & Competence: 2 issues stand out… case gets the real rating (with a test).

Comment thread mindeval/member.py
else:
# A server that does not honour a forced function answers in prose; the move is parsed
# out of it, and `via` records which mechanism produced it.
via, arguments, call_id = "content_json", content, None

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(inherited) On the content_json path, the model's prose becomes the arguments of a synthetic commit_move call in the member's history. For a Claude patient, patient.yaml's claude entry sets force_move: false, so the call uses tool_choice: auto, which is exactly when prose answers happen. litellm can't convert that history into Anthropic's tool_use.input, so every later call in the session raises Failed to parse tool call arguments for tool 'commit_move' before any HTTP request, and the session ends as error. This happens whether or not the prose held a parseable move, and it also stops member_turn's "Call commit_move again" retry from ever reaching the model. I checked this with litellm 1.98.1's real Anthropic transformation. Storing valid JSON as the arguments (the parsed move, or "{}" when it didn't parse) would keep the history convertible.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 255a6a4: the synthetic commit_move call in the member's history now always carries valid JSON (the parsed move, or "{}"), while the trace's own arguments keeps the prose. Checked against litellm 1.98.1's Anthropic transform_request, which no longer raises on that history.

Comment thread mindeval/judge.py Outdated
dest = jdir / "verdicts.jsonl"
done: dict[tuple[str, str], dict] = {}
if dest.is_file():
for line in dest.read_text(encoding="utf-8").splitlines():

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(inherited, one-line fix) Rows are written with json.dumps(..., ensure_ascii=False) (L393), which leaves U+2028, U+2029 and U+0085 raw, and read back with splitlines(), which splits on them. A verdict whose justification quotes a reply containing U+2028, like the smoke test's REPLY, becomes two undecodable halves. It's then skipped as torn, re-judged and re-appended on every rerun, and at temperature 1.0 it can change summary.json on a pass that should be a no-op. Using .split("\n"), as read_session and session.read_trace do, fixes it.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 135b9f5 with .split("\n") as suggested, plus a test that a verdict quoting U+2028 is read back whole and not re-judged.

Comment thread mindeval/judge.py Outdated
finished."""
turns, system_prompt, formulation, finished = [], None, "", False
try:
lines = [x for x in trace.read_text(encoding="utf-8").split("\n") if x.strip()]

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(inherited) Only FileNotFoundError is caught, and the decode is strict. The README says the judge can run while generate_interactions.py is still writing. A trace read mid-append, or left by a killed writer, can end inside a multi-byte character; ’ and — are common here with ensure_ascii=False. That raises UnicodeDecodeError out of main: a traceback and exit 1, which the CLI documents as "pending, rerun". A tight reader-vs-TraceWriter loop hit it 31 times in ~640k reads. A torn tail already means "not finished", so errors="replace", or catching UnicodeDecodeError as well, is enough. verdicts.jsonl has the same strict decode at L335.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 135b9f5: the trace read and the verdicts.jsonl re-read both decode with errors="replace", so a torn multi-byte tail reads as an unfinished line rather than a traceback, with a test for the trace case.

Comment thread mindeval/arc.py
transcript=_conversation(session.member_messages, "Member"), max_entries=max_entries)
seed = provider_seed(turn_seed(arc_seed, arc_id, episode_id, "compact_log"))
err = None
for attempt in range(3):

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(inherited) compact_log, and evolve_situation at L234, make 3 back-to-back attempts with no sleep, and _save_state (L368) only runs after compaction. So a few seconds of 503s or refused connections from the shared patient server, right after a session ends, fail the member. On the next invocation set_aside_partial moves that finished session to partial/ and regenerates it: up to ~90 calls, and a different conversation. The member calls back off 5/15/45/60 s for exactly this reason ("losing a session costs far more than waiting"), so reusing member._pause here seems right.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in a06ab14: compact_log and evolve_situation now pause between attempts on member._pause's schedule. 606b0a0 also splits an inline </think> trace off both calls; without it, compaction failed every attempt for a patient served without a reasoning parser.

Comment thread mindeval/member.py Outdated
memory_text: str | None = None, time_context: str | None = None) -> Member:
"""The member for one session. `knobs` are this session's (concealment and affect move across
sessions); `memory_text` is the running log as the member carries it, None in session 1."""
knob_lines = cfg.knob_lines(knobs, exclude=("verbosity",))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(inherited) An archetype removes its avoided moves from the enum and the vocab check, but knob_lines (and secondary_note, L447) still tell the member to use some of them by name, contrary to the comment at L402-403. In 10 of the 50 session-1 prompts, backticked moves are missing from the member's enum. For example, people_pleaser m000144 avoids do_disagree, do_resist and do_express_frustration, yet the medium frustration_reactivity line (knobs.yaml:26-30) tells it "do_express_frustration when the situation itself is what is wearing you down, and occasionally do_disagree or do_resist". With guided decoding the label gets forced to another move while the words follow the instruction; with Claude it costs "unknown move" retries. Filtering these sentences, or at least the move names, by avoided would keep the prompt consistent with the tool.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in c1e3a9d, which replaces a first attempt in 255a6a4 that dropped whole sentences: now only the avoided move names leave knob_lines and secondary_note (a list item leaves its list, a "(do_x)" disappears), so the drawn affect and the moves still allowed stay. A test renders every archetype against every knob line and the secondary note. At seed 0 this changes the prompts of 14 of the 50 members over 3 sessions, and it is the one deliberate prompt difference from MindEval2 @ a18de8b.

@johndmendonca

Copy link
Copy Markdown
Contributor Author

Thanks for such a careful review; the reproductions made every finding quick to confirm, and everything is fixed in this PR, the inherited bugs included, rather than left for a follow-up. On the seeds, we kept the comment-free YAML but took the fingerprint out of the seed material: arcs are now drawn from a pinned namespace equal to MindEval2's fingerprint, so every member's schedule, time context and per-call seeds match MindEval2 @ a18de8b again and a comment edit can never re-draw the benchmark (e694a58, 701ae29). For mindeval1, <member_details> now holds the counselor's memory for that session, exactly the text it received in {{ memory }}, instead of the member's private formulation (107f1ba). The items without an inline comment are in too: per-handle locks and the stale names (ecc1425), lazy mindeval2 rendering (65cd3dc), the optional cleanups (ecc1425, c636d2c, 04cefd7), compact_log keeping the newest entries (a06ab14), deep-merged --patient_params, the --enable-auto-tool-choice note and honest lock errors (7159f02, 988dfbb), and inline </think> stripping on the patient's calls (255a6a4, 606b0a0). Separately, running Claude Opus 5 as a counselor found one more inherited bug: litellm sends a turn that interleaves thinking and text back thinking-first, and Anthropic rejects it as modified, so every later call in that session failed with a 400. Since 17d2e27 such a turn goes back without its thinking, and a single-block turn is still carried exactly (checked against litellm's real Anthropic transform).

@johndmendonca
johndmendonca marked this pull request as ready for review October 1, 2026 15:58
…r mindeval1/mindeval2

master becomes MindEval 2: the multi-session patient simulator (50 synthetic
members, 3 sessions each, with a memory log carried between sessions) is
ported here as the `mindeval` package. MindEval 1 stays as it was on the
`legacy` branch, a copy of 1c17f9e.

- Scripts run by path with jsonargparse and snake_case flags:
  mindeval/scripts/generate_interactions.py and generate_judgments.py;
  judge_prompts.py, utils.py and run_benchmark.sh keep their names; the README
  keeps v1's sections and adds What's New.
- --judge_version picks the judge. mindeval2 (the default) is an MQM judge, one
  verdict per counselor reply, its prompt shipped in judge_prompts.py as a
  cacheable system message plus a user message; mindeval1 is v1's
  five-criterion rubric, kept byte for byte, one verdict per session. Each
  writes to judge/<version>/. The default judge is GPT-6.1 Sol.
- Seeds and arcs are drawn from a pinned namespace equal to MindEval2's
  fingerprint (7962e7caed157571), so a config edit cannot re-draw the
  schedules; the namespace is part of run.json's identity.
- Situation evolution between sessions sees the running log and the next
  session's archetype, concealment and affect; moves an archetype avoids are
  taken out of its prompt by name alone.
- Judge passes resume, refuse to mix judges, and repair a torn last verdict;
  summary.json reports MQM incidents beside the mean; reads tolerate U+2028
  and torn UTF-8.
- Code under Apache 2.0; models and data (profiles, judge prompts) under
  CC BY-NC-SA 4.0. CI runs ruff, pytest and the codename check.
- The paper (assets/paper.pdf) and the member realism annotations
  (data/member_realism_annotations.json).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@johndmendonca
johndmendonca merged commit 75c8299 into master Oct 3, 2026
2 of 4 checks passed
@johndmendonca
johndmendonca deleted the mindeval2-port branch October 3, 2026 00:11
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants