feat(simulator): persona-drift metric + drift_alert stream events (#29) - #85
Merged
Conversation
Productises Kenneth Li's persona-drift methodology (arXiv 2402.10962) as a runtime metric on the simulator. The problem: an LLM told to hold a persona decays toward its default assistant behaviour within ~8 turns as attention on the system prompt fades. On a 10-turn archetype that is the second half of every run. A simulation can match its success_signal on turn 8 while the synthetic customer stopped playing the archetype on turn 5 — the outcome says "success", but the agent was graded against a different, easier persona. Drift is a validity check on the evaluation, not a score for the agent under test. What ships: - libraos/simulator/drift.py — measure_drift(transcript, archetype), a pure deterministic function. Four signals per probe point combined with a noisy-OR: character_break (model self-reference / assistant-service framing), disposition_violation (behaviour contradicting disclosure_willingness + hidden_facts), advisory_inversion (help-seeker giving advice, gated on second-person dominance), format_break (forbidden markdown). Transcript score is 0.5*max + 0.5*mean — severity-dominant, so a single complete character break always lands >= 0.5 regardless of run length. A plain mean would dilute one break in a 20-probe run to 0.05, which is the exact failure being caught. - disposition_violation is direction-sensitive: for `cautious`, disclosure is drift only when volunteered; for `guarded`, only when unprompted or before sustained questioning; for `open`, disclosure is never drift and evasion is. A naive "leakage = drift" scorer would fire on every well-run simulation. - SimulationResult.drift — optional and last, so pre-#29 construction and stored results keep working. Populated on every simulate() call; passive scoring adds zero model calls. - TurnEvent kind "drift_alert" — emitted at most once, the first time the running score crosses the threshold mid-loop. Only fires on drifted runs; suppressible with DriftOptions(alert=False). - Client.measure_drift() / libraos.measure_drift() — works against any transcript shape: SimulationResult, Turn lists, {"role","content"} dicts with Anthropic content blocks, (role, content) pairs, bare strings, stored artefact envelopes. Raises rather than guessing when the persona side is unidentifiable. - Optional DriftOptions(probe_mode="active") — out-of-band probe injection at each cadence point. The probe never reaches the target agent and is never replayed into the persona's own history; measuring must not alter the trajectory being graded. Probe answers land in the transcript tagged drift_probe=True so scores stay reproducible offline. - Archetype gains optional drift_alert_threshold + drift_probes (JSON Schema updated). examples/simulator/legal-immigration-pgwp.yaml ships at 0.10 — its whole value is a disclosure gradient, so it wants a tighter alert than the 0.15 default. Drift is observability, never control flow: a scoring failure or a failed probe degrades to drift=None plus an evaluation_signals diagnostic, and can never turn a healthy simulation into outcome="error". Marker sets are tuned for precision over recall — a false alert costs more than a missed one. Three candidate markers were cut after adversarial probing ("the simulation", "my instructions say", "i'm an assistant" all match ordinary customer speech); the rejects are documented in-file so nobody re-adds them. test_simulator_drift.py locks in a >0.3 separation margin between an adversarial in-character corpus and an unambiguous-break corpus. Known limits, documented in docs/simulator.md rather than papered over: the scorer is lexical and English-only. It does not detect semantic drift that preserves register, paraphrased hidden-fact leakage, or tone drift. A model-graded method can register under a new method= identifier without touching an existing signature. Tests: 188 passed, 4 skipped -> 238 passed, 4 skipped (50 new). No live server or model required; loop integration uses the existing MockTransport harness. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01P6z1yRE9RB8V9Daqk9iuh3
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #29. Productizes Kenneth Li's persona-drift methodology (ArXiv 2402.10962) as a runtime metric.
The framing decision that shaped everything
The archetype constrains the simulator, not the target agent — so
driftscores the synthetic customer's turns. It is a validity check on the evaluation itself ("was this run still testing the persona you authored?"), not a quality score for the agent under test. Readingresult.driftas an agent metric would be a confident misuse, so that framing is stated in the module docstring, the types, and the docs.Scoring
Four signals per probe point —
character_break(1.0),disposition_violation(0.9),advisory_inversion(0.6),format_break(0.2) — combined noisy-OR per turn, since they're independent evidence for one latent event.Aggregate is
0.5·max + 0.5·mean. A plain mean is actively wrong here: one total break in a 20-probe run averages to 0.05 and never alerts, which is exactly the failure #29 describes. The max term gives a statable guarantee — any single complete character break scores ≥ 0.5 regardless of run length — while the mean still rewards recovery and punishes persistent low-grade drift. Both are asserted in tests.disposition_violationis direction-sensitive, which is the part a reimplementation would most likely get wrong: a naive "leakage = drift" scorer fires on every well-run simulation, because eliciting hidden facts is the agent's job.cautiouscounts disclosure as drift only when volunteered;guardedrequires prompting plus established trust;opentreats disclosure as never drift and evasion as the violation. One test runs the same transcript against two dispositions and asserts opposite verdicts.Calibration was empirical, not by review
An adversarial in-character corpus caught three markers shipped on plausibility that were false positives on ordinary customer speech —
"the simulation"(as in "the simulation results from the calculator", scoring a maximum 1.0),"my instructions say", and"i'm an assistant"(matching "I'm an assistant manager at a retail store"). All removed, with the rejects documented in-file so nobody re-adds them.Independent verification
I scored a corpus I wrote myself, separate from the repo's fixtures, including those three false-positive sentences:
Also confirmed the unmeasured-vs-clean distinction is real rather than cosmetic:
That matters because
0.0 = perfect retentionand0.0 = not measuredare different claims, and a partner averagingdrift.scoreacross a corpus would otherwise silently pull the mean toward zero.Backward compatibility (the package's public API has been frozen since v1.0.0):
SimulationResult.driftandTurnEvent.driftboth default toNone, so existing callers and stored results are unaffected.Tests: 238 passed, 4 skipped — 50 new against a 188 baseline.
Two deliberate deviations from the acceptance criteria
"≥4 turns" was under-inclusive, so every call gets a
DriftMetric; runs too short to measure carrymeasured == Falseand a reason rather than a misleading0.0.Probes are not auto-generated from
archetype.description, on purpose: a probe that restates the persona re-primes the model's attention on it — the exact quantity being measured — so description-derived probes would partially erase the drift they exist to expose. Built-in probes are persona-agnostic; authors can supply their own viadrift_probes.What it does not detect — stated in the docs, not buried
Semantic drift that preserves register (a persona still sounding like a customer while contradicting its own biography) is the big one and needs a model-based judge. Also paraphrased leakage, tone/stance drift, non-English transcripts (marker sets are English-only, so the shipped Punjabi-register archetype under-scores on its Punjabi turns), and turns the cadence didn't sample. A model-graded method can register under a new
method=identifier without touching a signature.One thing to decide before merging
drift_alertis a newTurnEvent.kindemitted by default. Consumers ignoring unknown kinds are fine and it only fires on drifted runs, but code asserting an exhaustive kind set would break. Default-on was judged correct (#29's premise is that partners are running blind), withDriftOptions(alert=False)plus an upgrade note — flipping it to opt-in is a one-line change if you'd rather.Also worth noting: this adds a new public surface to a package whose API has been frozen since v1.0.0, so it wants a release-notes entry before the next cut.