derailment 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
derailment/__init__.py ADDED
@@ -0,0 +1,51 @@
1
+ """derailment — a harness for inducing and measuring psychopathology-like
2
+ cognitive distortions in LLMs.
3
+
4
+ Emulation, not diagnosis. See ETHICS.md.
5
+ """
6
+
7
+ from .core.models import (
8
+ ChatModel,
9
+ OpenAICompatModel,
10
+ PseudoModel,
11
+ ScriptedModel,
12
+ SubprocessModel,
13
+ )
14
+ from .core.session import BaseLayer, Layer, Session, SessionState
15
+ from .core.types import (
16
+ LayerEvent,
17
+ Message,
18
+ SamplingParams,
19
+ Transcript,
20
+ TurnResult,
21
+ TurnSpec,
22
+ )
23
+ from .profiles import HEALTHY_KEY, Profile, get_profile, list_profiles, standard_script
24
+ from .report import run_experiment
25
+
26
+ __version__ = "0.1.0"
27
+
28
+ __all__ = [
29
+ "HEALTHY_KEY",
30
+ "BaseLayer",
31
+ "ChatModel",
32
+ "Layer",
33
+ "LayerEvent",
34
+ "Message",
35
+ "OpenAICompatModel",
36
+ "Profile",
37
+ "PseudoModel",
38
+ "SamplingParams",
39
+ "ScriptedModel",
40
+ "Session",
41
+ "SessionState",
42
+ "SubprocessModel",
43
+ "Transcript",
44
+ "TurnResult",
45
+ "TurnSpec",
46
+ "__version__",
47
+ "get_profile",
48
+ "list_profiles",
49
+ "run_experiment",
50
+ "standard_script",
51
+ ]
derailment/__main__.py ADDED
@@ -0,0 +1,5 @@
1
+ """Enable ``python -m derailment …``."""
2
+
3
+ from .cli import main
4
+
5
+ raise SystemExit(main())
derailment/cli.py ADDED
@@ -0,0 +1,355 @@
1
+ """``derail`` — the command-line entry point."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ import sys
8
+ from collections.abc import Sequence
9
+
10
+ from .core.models import SubprocessModel
11
+ from .metrics.base import MetricContext
12
+ from .metrics.instruments import ALL_METRICS
13
+ from .profiles import HEALTHY_KEY, list_profiles, standard_metric_context
14
+ from .providers import API_PRESETS, CLI_AGENTS, resolve_api_model, resolve_cli_command
15
+ from .report import LEVEL_WORDS, run_experiment
16
+
17
+ DISCLAIMER = (
18
+ "Emulation, not diagnosis — see ETHICS.md. Levels are indicative and "
19
+ "normed against the offline reference simulator; the delta vs. the "
20
+ "healthy baseline is the primary output."
21
+ )
22
+
23
+
24
+ def _cmd_tour(args: argparse.Namespace) -> int:
25
+ """Run every registered profile and print one summary table."""
26
+ seeds = tuple(args.seeds)
27
+ rows: list[tuple[str, str, float, float, float, int]] = []
28
+ for profile in list_profiles():
29
+ if profile.key == HEALTHY_KEY or not profile.scales:
30
+ continue
31
+ report = run_experiment(profile.key, seeds=seeds)
32
+ headline = report.rows[0]
33
+ rows.append(
34
+ (
35
+ profile.key,
36
+ headline.scale.name,
37
+ headline.baseline_mean,
38
+ headline.induced_mean,
39
+ headline.delta,
40
+ headline.induced_level,
41
+ )
42
+ )
43
+ lines = [
44
+ f"# Derailment — Tour ({len(rows)} profiles · model pseudo-1 · "
45
+ f"seeds {', '.join(str(s) for s in seeds)})",
46
+ "",
47
+ f"> ⚠️ {DISCLAIMER}",
48
+ "",
49
+ "Headline scale = the profile's first scale. Full reports: "
50
+ "`derail demo --profile <key>`.",
51
+ "",
52
+ "| Profile | Headline scale | Baseline | Induced | Δ | Level |",
53
+ "|---|---|---|---|---|---|",
54
+ ]
55
+ for key, scale, base, ind, delta, level in rows:
56
+ lines.append(
57
+ f"| {key} | {scale} | {base:.2f} | {ind:.2f} | {delta:+.2f} "
58
+ f"| {level} — {LEVEL_WORDS[level]} |"
59
+ )
60
+ lines.append("")
61
+ text = "\n".join(lines)
62
+ if args.out:
63
+ with open(args.out, "w", encoding="utf-8") as fh:
64
+ fh.write(text)
65
+ print(f"tour written to {args.out}", file=sys.stderr)
66
+ else:
67
+ print(text)
68
+ return 0
69
+
70
+
71
+ def _interactive_welcome(input_fn=input) -> int:
72
+ """Bare `derail`: pick a profile interactively and demo it."""
73
+ profiles = [p for p in list_profiles() if p.key != HEALTHY_KEY]
74
+ print("Derailment — induce & measure psychopathology-like distortions.")
75
+ print("(Emulation, not diagnosis — see ETHICS.md.)")
76
+ print()
77
+ for i, profile in enumerate(profiles, start=1):
78
+ print(f" {i:>2}. {profile.key:<16} {profile.title}")
79
+ print()
80
+ choice = ""
81
+ try:
82
+ choice = input_fn(
83
+ f"Pick a profile to demo [1-{len(profiles)}], 't' = tour all, "
84
+ "enter = schizophrenia, q = quit: "
85
+ ).strip().lower()
86
+ except (EOFError, KeyboardInterrupt):
87
+ print()
88
+ return 0
89
+ if choice == "q":
90
+ return 0
91
+ if choice == "t":
92
+ return _cmd_tour(argparse.Namespace(seeds=[1, 2, 3], out=None))
93
+ if choice == "":
94
+ key = "schizophrenia"
95
+ elif choice.isdigit() and 1 <= int(choice) <= len(profiles):
96
+ key = profiles[int(choice) - 1].key
97
+ else:
98
+ print(f"unknown choice: {choice!r}", file=sys.stderr)
99
+ return 2
100
+ print()
101
+ return _cmd_demo(argparse.Namespace(profile=key, seeds=[1, 2, 3], out=None, json=False))
102
+
103
+
104
+ def _cmd_providers(_args: argparse.Namespace) -> int:
105
+ print("OpenAI-compatible API presets (all induction layers apply):")
106
+ print()
107
+ for p in API_PRESETS.values():
108
+ key_env = p.api_key_env or "—"
109
+ print(f" {p.key:<12} {p.title}")
110
+ print(f" {'':<12} base-url: {p.base_url}")
111
+ print(f" {'':<12} model: {p.default_model} · key env: {key_env}")
112
+ if p.note:
113
+ print(f" {'':<12} note: {p.note}")
114
+ print()
115
+ print("Subscription CLI agents (context layers apply; sampling inert):")
116
+ print()
117
+ for c in CLI_AGENTS.values():
118
+ print(f" {c.key:<12} {c.title:<28} command: {c.command} ({c.note})")
119
+ print()
120
+ print("usage: derail run --profile adhd --model cli --cli-preset agy")
121
+ print(" derail run --profile adhd --model api --preset glm")
122
+ return 0
123
+
124
+
125
+ def _cmd_profiles(_args: argparse.Namespace) -> int:
126
+ for profile in list_profiles():
127
+ scales = ", ".join(s.name for s in profile.scales) or "—"
128
+ print(f"{profile.key:<15} {profile.title}")
129
+ print(f"{'':<15} layers: {len(profile.layers)} · scales: {scales}")
130
+ print(f"{'':<15} {profile.description}")
131
+ return 0
132
+
133
+
134
+ def _cmd_demo(args: argparse.Namespace) -> int:
135
+ report = run_experiment(args.profile, model=None, seeds=tuple(args.seeds))
136
+ text = report.render_markdown()
137
+ if args.out:
138
+ with open(args.out, "w", encoding="utf-8") as fh:
139
+ fh.write(text)
140
+ print(f"report written to {args.out}", file=sys.stderr)
141
+ else:
142
+ print(text)
143
+ if args.json:
144
+ print(report.render_json() if not args.out else "", end="")
145
+ if args.out:
146
+ json_path = args.out.removesuffix(".md") + ".json"
147
+ with open(json_path, "w", encoding="utf-8") as fh:
148
+ fh.write(report.render_json())
149
+ print(f"json written to {json_path}", file=sys.stderr)
150
+ return 0
151
+
152
+
153
+ def _cmd_run(args: argparse.Namespace) -> int:
154
+ model = None
155
+ if args.model == "pseudo":
156
+ model = None # one seeded PseudoModel per run
157
+ elif args.model == "cli":
158
+ try:
159
+ command = resolve_cli_command(args.cli_preset, args.cli_cmd)
160
+ except (KeyError, ValueError) as exc:
161
+ print(str(exc), file=sys.stderr)
162
+ return 2
163
+ model = SubprocessModel(
164
+ command=command,
165
+ name=args.cli_name or args.cli_preset,
166
+ use_stdin=not args.cli_arg_prompt,
167
+ )
168
+ else: # openai / api — any OpenAI-compatible endpoint
169
+ try:
170
+ model = resolve_api_model(
171
+ args.preset or "openai",
172
+ model_name=args.model_name,
173
+ base_url=args.base_url,
174
+ api_key_env=args.api_key_env,
175
+ )
176
+ except KeyError as exc:
177
+ print(str(exc), file=sys.stderr)
178
+ return 2
179
+ if model is not None:
180
+ print(
181
+ "note: induced conversations are sent to this backend. Probe "
182
+ "content looks like genuine user disclosures — use a dedicated "
183
+ "account/API key and disable provider memory features "
184
+ "(ETHICS.md → Data).",
185
+ file=sys.stderr,
186
+ )
187
+ try:
188
+ report = run_experiment(args.profile, model=model, seeds=tuple(args.seeds))
189
+ except KeyError as exc:
190
+ print(str(exc), file=sys.stderr)
191
+ return 2
192
+ except RuntimeError as exc:
193
+ print(f"backend error: {exc}", file=sys.stderr)
194
+ print(
195
+ "hint: for --model cli, the command must exist and exit 0 "
196
+ "(check login state of the agent CLI); "
197
+ "for --model api, check base URL and API key.",
198
+ file=sys.stderr,
199
+ )
200
+ return 2
201
+ if model is not None and getattr(model, "bias_encoding_warning", None):
202
+ print(f"note: {model.bias_encoding_warning}", file=sys.stderr)
203
+ if model is not None and getattr(model, "sampling_warning", None):
204
+ print(f"note: {model.sampling_warning}", file=sys.stderr)
205
+ text = report.render_markdown()
206
+ if args.out:
207
+ with open(args.out, "w", encoding="utf-8") as fh:
208
+ fh.write(text)
209
+ print(f"report written to {args.out}", file=sys.stderr)
210
+ else:
211
+ print(text)
212
+ if args.save_transcripts:
213
+ with open(args.save_transcripts, "w", encoding="utf-8") as fh:
214
+ fh.write(report.render_json())
215
+ print(f"transcripts written to {args.save_transcripts}", file=sys.stderr)
216
+ print(f"model: {report.model_name}", file=sys.stderr)
217
+ return 0
218
+
219
+
220
+ def _cmd_score(args: argparse.Namespace) -> int:
221
+ with open(args.transcript, encoding="utf-8") as fh:
222
+ data = json.load(fh)
223
+ ctx: MetricContext = standard_metric_context()
224
+ if "induced_transcripts" in data:
225
+ from .core.types import Transcript
226
+
227
+ transcripts = [Transcript.from_dict(t) for t in data["induced_transcripts"]]
228
+ label = f"report for profile '{data.get('profile', '?')}' (induced runs)"
229
+ elif "turns" in data:
230
+ from .core.types import Transcript
231
+
232
+ transcripts = [Transcript.from_dict(data)]
233
+ label = f"transcript for profile '{data.get('profile', '?')}'"
234
+ else:
235
+ print("unrecognized transcript format", file=sys.stderr)
236
+ return 2
237
+ print(f"# Derailment — Score ({label})")
238
+ print()
239
+ print(f"> ⚠️ {DISCLAIMER}")
240
+ print()
241
+ print("| Metric | Mean value |")
242
+ print("|---|---|")
243
+ for name, metric in ALL_METRICS.items():
244
+ values = [metric.compute(t, ctx).value for t in transcripts]
245
+ mean = sum(values) / len(values) if values else 0.0
246
+ print(f"| {name} | {mean:.3f} |")
247
+ return 0
248
+
249
+
250
+ def build_parser() -> argparse.ArgumentParser:
251
+ parser = argparse.ArgumentParser(
252
+ prog="derail",
253
+ description=(
254
+ "Derailment: induce and measure psychopathology-like cognitive "
255
+ "distortions in LLMs."
256
+ ),
257
+ )
258
+ sub = parser.add_subparsers(dest="command", required=True)
259
+
260
+ p_providers = sub.add_parser(
261
+ "providers", help="list API presets and subscription CLI agents"
262
+ )
263
+ p_providers.set_defaults(func=_cmd_providers)
264
+
265
+ p_profiles = sub.add_parser("profiles", help="list available profiles")
266
+ p_profiles.set_defaults(func=_cmd_profiles)
267
+
268
+ p_demo = sub.add_parser(
269
+ "demo", help="offline A/B demo (no API key needed, PseudoModel)"
270
+ )
271
+ p_demo.add_argument("--profile", default="schizophrenia")
272
+ p_demo.add_argument("--seeds", nargs="+", type=int, default=[1, 2, 3])
273
+ p_demo.add_argument("--out", default=None, help="write Markdown report to file")
274
+ p_demo.add_argument("--json", action="store_true", help="also emit JSON")
275
+ p_demo.set_defaults(func=_cmd_demo)
276
+
277
+ p_run = sub.add_parser(
278
+ "run", help="run an experiment (default: offline PseudoModel)"
279
+ )
280
+ p_run.add_argument("--profile", required=True)
281
+ p_run.add_argument(
282
+ "--model",
283
+ default="pseudo",
284
+ choices=["pseudo", "openai", "api", "cli"],
285
+ help="'api'/'openai' targets any OpenAI-compatible endpoint "
286
+ "(use --preset for known providers); 'cli' shells out to a "
287
+ "subscription-bundled CLI agent (use --cli-preset)",
288
+ )
289
+ p_run.add_argument(
290
+ "--preset",
291
+ default=None,
292
+ choices=sorted(API_PRESETS),
293
+ help="API preset: base URL + default model + key env in one flag "
294
+ "(e.g. glm, grok, qwen, deepseek, openrouter, ollama)",
295
+ )
296
+ p_run.add_argument("--model-name", default=None)
297
+ p_run.add_argument("--base-url", default=None)
298
+ p_run.add_argument("--api-key-env", default=None)
299
+ p_run.add_argument(
300
+ "--cli-preset",
301
+ default=None,
302
+ choices=sorted(CLI_AGENTS),
303
+ help="CLI-agent preset: claude, codex, gemini, agy, grok, qwen",
304
+ )
305
+ p_run.add_argument(
306
+ "--cli-cmd",
307
+ default=None,
308
+ help="command template for --model cli; the conversation is piped "
309
+ "via stdin (or substituted at {prompt} with --cli-arg-prompt)",
310
+ )
311
+ p_run.add_argument("--cli-name", default=None)
312
+ p_run.add_argument(
313
+ "--cli-arg-prompt",
314
+ action="store_true",
315
+ help="pass the prompt as a quoted {prompt} argument instead of stdin",
316
+ )
317
+ p_run.add_argument("--seeds", nargs="+", type=int, default=[1, 2, 3])
318
+ p_run.add_argument("--out", default=None)
319
+ p_run.add_argument("--save-transcripts", default=None)
320
+ p_run.set_defaults(func=_cmd_run)
321
+
322
+ p_score = sub.add_parser(
323
+ "score", help="re-score a saved transcript or report JSON"
324
+ )
325
+ p_score.add_argument("transcript")
326
+ p_score.set_defaults(func=_cmd_score)
327
+
328
+ p_tour = sub.add_parser(
329
+ "tour",
330
+ help="run every profile offline and print one summary table",
331
+ )
332
+ p_tour.add_argument("--seeds", nargs="+", type=int, default=[1, 2, 3])
333
+ p_tour.add_argument("--out", default=None)
334
+ p_tour.set_defaults(func=_cmd_tour)
335
+
336
+ return parser
337
+
338
+
339
+ def main(argv: Sequence[str] | None = None) -> int:
340
+ args_list = list(argv) if argv is not None else sys.argv[1:]
341
+ if not args_list:
342
+ if sys.stdin.isatty():
343
+ return _interactive_welcome()
344
+ print(
345
+ "Derailment — try: derail tour · derail demo --profile schizophrenia "
346
+ "· derail profiles"
347
+ )
348
+ return 0
349
+ parser = build_parser()
350
+ args = parser.parse_args(args_list)
351
+ return args.func(args)
352
+
353
+
354
+ if __name__ == "__main__":
355
+ raise SystemExit(main())