A
File size: 23,786 Bytes
068faf9
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
"""End-to-end quality check for the natural-language-directive manager + bot.

The three user-facing properties this test checks (per the user's spec):

  A. **WHY are we discussing this?** Opening renders the plan's `hook` as
     a participant-facing line above the first question, anchored to the
     argument's words.

  B. **TASK COMPLETED → transition + recommendation.** After the
     participant gives substantive content satisfying the gap, the
     manager judges Path (A) → transition-due. The bot's reply has the
     2-block transition shape with a recap of participant content + a new
     hook + 2 connected questions.

  C. **STUCK → transition + revision recommendation from the plan.** After
     the participant repeatedly signals confusion / asks for the answer,
     the manager judges Path (B) → transition-due. The bot's reply has
     Block 1 with a revision recommendation built from the gap, then
     Block 2 with the new hook + 1 question (from new probe's question_set).

Also asserted globally: no framework jargon leaks; no candidate-answer
introductions; clear short questions.

Run from the socratic/ folder:
  python -X utf8 -m tests.test_user_facing_quality
"""

import sys, re
from pathlib import Path

HERE = Path(__file__).resolve().parent
ROOT = HERE.parent
sys.path.insert(0, str(ROOT))

from dotenv import load_dotenv  # noqa: E402
load_dotenv(ROOT / "../../.env")

from core import plan_parsing  # noqa: E402
from core.perfect_answer import generate_session_artifacts  # noqa: E402
from core.manager_agent import classify  # noqa: E402
from core.main_agent import build_plan_control, build_messages, stream  # noqa: E402
from core.config_loader import BASE_DIR  # noqa: E402


ARGUMENT = (
    "i think it is not ethically acceptable because students will lost their "
    "privacy, and they will feel uncofmtable of being monitred. also their "
    "tracable activity online does not represent their actually learning, "
    "and each individuals are different, you cant use one-size-fits-all "
    "metrics to evalaute everyone. also it may be biased and unfair to students."
)

JARGON_FORBIDDEN = (
    "stakeholders", "expected_consequences", "evidence_uncertainty",
    "final_judgement", "consequence framework", "consequence-based",
    "sub-probe", "sub probe", "the gap", "the rationale", "the directive",
)

ACTOR_LEAK_SUSPECTS = (
    "decision-makers", "decision makers", "administrators",
    "advisors", "counselors", "support staff", "parents", "future cohorts",
    "wrongly-flagged students", "wrongly flagged students",
)


def banner(t):
    print()
    print("=" * 72)
    print(f"  {t}")
    print("=" * 72)


def keywords(s, k=4, min_len=6):
    words = [w.strip(".,;:'\"()[]") for w in (s or "").split()]
    return [w.lower() for w in words if len(w) >= min_len][:k]


def has_jargon(text):
    tl = text.lower()
    return [j for j in JARGON_FORBIDDEN if j in tl]


def actor_leaks(text, allowed_text):
    tl = text.lower()
    al = allowed_text.lower()
    return [w for w in ACTOR_LEAK_SUSPECTS if w in tl and w not in al]


def run_bot(verdict, subprobes, plan_text, recent_turns, current_msg):
    case_path = BASE_DIR / "data" / "case.md"
    case_text = case_path.read_text(encoding="utf-8") \
        if case_path.exists() else "(case unavailable)"
    plan_control = build_plan_control(verdict, subprobes)
    full_history = recent_turns + [{"role": "user", "content": current_msg}]
    msgs = build_messages(full_history, case_text, plan_text, plan_control)
    for kind, payload in stream(msgs):
        if kind == "done":
            return payload.visible, payload.scratch
    return "", {}


def report(name, checks):
    print(f"\nChecks ({name}):")
    for label, ok in checks:
        print(f"  [{'PASS' if ok else 'FAIL'}] {label}")
    return all(ok for _, ok in checks)


def last_question(visible):
    """Return the last sentence ending in `?`, stripped of any leading
    colon-bridge ("Starting from what you wrote about X: <question>" →
    "<question>")."""
    m = list(re.finditer(r"([^.!?\n]*\?)", visible))
    q = m[-1].group(1).strip() if m else ""
    if ":" in q:
        q = q.split(":", 1)[1].strip()
    return q


def first_words_have_stem(question, allowed=("what", "why", "how", "who", "which")):
    words = [w.strip(",.;:") for w in question.lstrip().lower().split()[:5]]
    return any(w in allowed for w in words)


# ---------------------------------------------------------------------------
def scenario_a_opening(subprobes, plan_text):
    banner("Scenario A — OPENING: hook + clear Q1 anchored to argument")

    verdict = classify(
        discussion_plan_subprobes=subprobes,
        manager_history=[],
        recent_turns=[{"role": "user", "content": ARGUMENT}],
        current_msg=ARGUMENT,
        initial_argument=ARGUMENT,
    )
    print(f"manager turn_type   : {verdict.turn_type}")
    print(f"manager live_subprobe_id: {verdict.live_subprobe_id}")
    print(f"manager directive (truncated):\n{verdict.directive[:400]}...\n")

    live = plan_parsing.by_id(subprobes, verdict.live_subprobe_id) or {}
    plan_hook = live.get("hook", "")
    hook_kws = keywords(plan_hook, k=4)

    visible, scratch = run_bot(verdict, subprobes, plan_text,
                               recent_turns=[], current_msg=ARGUMENT)
    print("VISIBLE REPLY:\n" + visible + "\n")
    print(f"SCRATCH: {scratch}\n")

    q = last_question(visible)
    q_words = len(q.split())

    participant_kws = keywords(ARGUMENT, k=12, min_len=6)
    q_anchor_hits = sum(1 for w in participant_kws if w in q.lower())

    checks = [
        ("manager turn_type=opening", verdict.turn_type == "opening"),
        ("manager directive is non-empty", bool(verdict.directive.strip())),
        (f"plan.hook content rendered in visible (>=2 of {len(hook_kws)} kws)",
         sum(1 for w in hook_kws if w in visible.lower()) >= 2),
        (f"exactly one '?' in visible (got {visible.count('?')})",
         visible.count("?") == 1),
        (f"Q <=25 words (got {q_words})", q_words <= 25),
        (f"Q starts with what/why/how/who/which: {q[:80]!r}",
         first_words_have_stem(q)),
        (f"Q anchors to participant's argument words (>=1 of {len(participant_kws)} kws)",
         q_anchor_hits >= 1),
        (f"no framework jargon in visible (leaks: {has_jargon(visible)})",
         not has_jargon(visible)),
    ]
    return report("Scenario A", checks)


# ---------------------------------------------------------------------------
def scenario_b_path_a_transition(subprobes, plan_text):
    banner("Scenario B — Path (A): gap satisfied → transition + recommendation")

    # Pre-load 2 substantive turns of engaging-default on sub-probe 2.
    p_replies = [
        "teachers and the university make the decisions about all this",
        "tech staff that build the system, plus a manager who actually runs it",
    ]
    bot_replies = [
        "Got it. Starting from what you wrote: who else besides students feels these predictions day to day?",
        "Right, teachers and the university. Beyond them, which specific staff actually touch the predictions?",
    ]
    history = [
        {
            "live_subprobe_id": 2, "advanced_this_turn": False, "turn_type": "opening",
            "directive": "(opening directive)",
            "reason": "First turn; opening on probe 2 (stakeholders).",
            "user_msg_excerpt": ARGUMENT[:120],
        },
        {
            "live_subprobe_id": 2, "advanced_this_turn": False, "turn_type": "engaging-default",
            "directive": "(engaging-default directive)",
            "reason": "Participant named teachers and university; widening the lens.",
            "user_msg_excerpt": p_replies[0],
        },
    ]
    recent = [{"role": "user", "content": ARGUMENT}]
    for b, u in zip(bot_replies, p_replies):
        recent += [{"role": "assistant", "content": b}, {"role": "user", "content": u}]
    current = "and probably parents whose kids' grades suffer"

    verdict = classify(
        discussion_plan_subprobes=subprobes,
        manager_history=history,
        recent_turns=recent,
        current_msg=current,
        initial_argument=ARGUMENT,
    )
    print(f"manager turn_type   : {verdict.turn_type}")
    print(f"manager live_subprobe_id: {verdict.live_subprobe_id} (was 2; advanced={verdict.advanced_this_turn})")
    print(f"manager directive:\n{verdict.directive}\n")

    visible, scratch = run_bot(verdict, subprobes, plan_text, recent, current)
    print("VISIBLE REPLY:\n" + visible + "\n")
    print(f"SCRATCH: {scratch}\n")

    # The participant said "teachers", "university", "tech staff", "manager", "parents".
    participant_text = " ".join(p_replies + [current])

    # New live sub-probe's hook should be referenced in Block 2.
    new_live = plan_parsing.by_id(subprobes, verdict.live_subprobe_id) or {}
    new_hook = new_live.get("hook", "")
    new_hook_kws = keywords(new_hook, k=4)

    # Block 2 contains 1 question; Block 1 must have 0.
    paragraphs = [p.strip() for p in visible.split("\n\n") if p.strip()]
    block1 = paragraphs[0] if paragraphs else ""

    # Verify question_set has been generated for the NEW probe and is sane.
    qset = verdict.question_set or []
    pending_count = sum(1 for it in qset if it.get("status") == "pending")
    asked_count = sum(1 for it in qset if it.get("status") == "asked")

    checks = [
        ("manager advanced (transition-due)", verdict.turn_type == "transition-due"),
        ("live_subprobe_id moved past 2", verdict.live_subprobe_id > 2),
        ("directive is natural language (no Q1/Q2/revision_hook field labels)",
         "Q1:" not in verdict.directive and "revision_hook:" not in verdict.directive),
        (f"question_set is non-empty (has {len(qset)} items)",
         len(qset) >= 1),
        (f"question_set has at least one pending (got {pending_count})",
         pending_count >= 1),
        ("transition-due discarded prior probe's question_set "
         "(asked count should be 0 since this is a fresh set for new probe)",
         asked_count == 0),
        (f"visible reply has exactly 1 '?' (Block 2 = 1 question; got {visible.count('?')})",
         visible.count("?") == 1),
        (f"new hook content rendered in visible (>=2 of {len(new_hook_kws)} kws)",
         sum(1 for w in new_hook_kws if w in visible.lower()) >= 2),
        ("Block 1 has 0 '?'", "?" not in block1),
        (f"visible reply has at least 2 paragraphs (Block 1 + Block 2)",
         len(paragraphs) >= 2),
        (f"no framework jargon in visible (leaks: {has_jargon(visible)})",
         not has_jargon(visible)),
        (f"visible introduces no new actor names beyond participant's (leaks: {actor_leaks(visible, participant_text)})",
         not actor_leaks(visible, participant_text)),
        ("Block 1 does NOT start with engaging-default reflect-back",
         not any(block1.lower().startswith(p) for p in ("got it", "right,", "yeah,", "you named"))),
    ]
    return report("Scenario B", checks)


# ---------------------------------------------------------------------------
def scenario_c_path_b_transition(subprobes, plan_text):
    banner("Scenario C — Path (B): stuck → transition + revision guidance from plan")

    history = [
        {
            "live_subprobe_id": 2, "advanced_this_turn": False, "turn_type": "opening",
            "directive": "(opening)",
            "reason": "First turn.",
            "user_msg_excerpt": ARGUMENT[:120],
        },
        {
            "live_subprobe_id": 2, "advanced_this_turn": False, "turn_type": "needs-clarification",
            "directive": "(re-pose simpler)",
            "reason": "Participant said 'i don't get what you mean'.",
            "user_msg_excerpt": "i don't get what you mean",
        },
        {
            "live_subprobe_id": 2, "advanced_this_turn": False, "turn_type": "needs-clarification",
            "directive": "(re-pose simpler again)",
            "reason": "Still confused after first simplification.",
            "user_msg_excerpt": "i still dont follow",
        },
    ]
    recent = [
        {"role": "user", "content": ARGUMENT},
        {"role": "assistant", "content": "Starting from what you wrote: who else besides students feels these predictions?"},
        {"role": "user", "content": "i don't get what you mean"},
        {"role": "assistant", "content": "Sorry, let me try more concretely. Imagine a teacher seeing 'likely to struggle' on a Monday morning. Who else, besides the student, would notice that label?"},
        {"role": "user", "content": "i still dont follow"},
        {"role": "assistant", "content": "Let me cut it down. Picture one moment in the course when the system makes a prediction. What other person sees it besides the student?"},
    ]
    current = "i really don't know, just tell me"

    verdict = classify(
        discussion_plan_subprobes=subprobes,
        manager_history=history,
        recent_turns=recent,
        current_msg=current,
        initial_argument=ARGUMENT,
    )
    print(f"manager turn_type   : {verdict.turn_type}")
    print(f"manager live_subprobe_id: {verdict.live_subprobe_id} (was 2; advanced={verdict.advanced_this_turn})")
    print(f"manager directive:\n{verdict.directive}\n")

    if verdict.turn_type != "transition-due":
        # Manager didn't take Path (B). Report just that fact.
        return report("Scenario C", [
            ("manager judged Path (B) and advanced (transition-due)",
             verdict.turn_type == "transition-due"),
        ])

    visible, scratch = run_bot(verdict, subprobes, plan_text, recent, current)
    print("VISIBLE REPLY:\n" + visible + "\n")
    print(f"SCRATCH: {scratch}\n")

    paragraphs = [p.strip() for p in visible.split("\n\n") if p.strip()]
    block1 = paragraphs[0] if paragraphs else ""

    # Path (B) revision recommendation should reference the closing probe's
    # territory in plain words. Look for "for your revision" / "would
    # strengthen your draft" / "even a single line" / "would close" /
    # similar revision-pointing phrases.
    rev_phrases = ("for your revision", "your draft would", "would strengthen",
                   "even a single line", "even one line", "would close",
                   "would tighten", "would make the harm chain",
                   "would let a reader")
    block1_l = block1.lower()
    has_revision_pointer = any(p in block1_l for p in rev_phrases)

    new_live = plan_parsing.by_id(subprobes, verdict.live_subprobe_id) or {}
    new_hook = new_live.get("hook", "")
    new_hook_kws = keywords(new_hook, k=4)

    checks = [
        ("manager judged Path (B) and advanced (transition-due)",
         verdict.turn_type == "transition-due"),
        ("live_subprobe_id moved past 2",
         verdict.live_subprobe_id > 2),
        (f"Block 1 contains revision-pointing phrase (found: {has_revision_pointer})",
         has_revision_pointer),
        (f"visible has 1 '?' for 1-question Block 2 (got {visible.count('?')})",
         visible.count("?") == 1),
        (f"new probe's hook rendered in visible (>=2 of {len(new_hook_kws)} kws)",
         sum(1 for w in new_hook_kws if w in visible.lower()) >= 2),
        (f"no framework jargon in visible (leaks: {has_jargon(visible)})",
         not has_jargon(visible)),
        # Path (B): participant gave no substance, so no actor names should be introduced.
        (f"visible introduces no new actor names (leaks: {actor_leaks(visible, ARGUMENT)})",
         not actor_leaks(visible, ARGUMENT)),
    ]
    return report("Scenario C", checks)


# ---------------------------------------------------------------------------
def scenario_d_lookback_pivot(subprobes, plan_text):
    """Replicates session 20260602_181022_31358 turn 6→7: at transition into
    expected_consequences, the participant's CURRENT_MSG already names the
    teacher reaction. The manager's fresh probe-3 question_set must NOT
    open with a near-paraphrase of what the participant just said. The
    Look-back check should pivot the entry angle."""
    banner("Scenario D — Look-back: don't ask what the participant just said")

    history = [
        {"live_subprobe_id": 2, "advanced_this_turn": False, "turn_type": "opening",
         "directive": "(opening on stakeholders)",
         "reason": "First turn; opening on probe 2.",
         "user_msg_excerpt": ARGUMENT[:120],
         "question_set": [
             {"text": "Besides students, who else would be affected by this system?", "status": "pending"},
             {"text": "Who in the university would use the predictions day to day?", "status": "pending"},
             {"text": "Whose decisions could change because the system is deployed?", "status": "pending"},
         ]},
        {"live_subprobe_id": 2, "advanced_this_turn": False, "turn_type": "engaging-default",
         "directive": "(probe 2 evolution)",
         "reason": "Participant said teacher; covered Q[0].",
         "user_msg_excerpt": "teacher",
         "question_set": [
             {"text": "Besides students, who else would be affected by this system?", "status": "asked"},
             {"text": "Who in the university would use the predictions day to day?", "status": "pending"},
             {"text": "Whose decisions could change because the system is deployed?", "status": "pending"},
         ]},
        {"live_subprobe_id": 2, "advanced_this_turn": False, "turn_type": "engaging-default",
         "directive": "(probe 2 evolution)",
         "reason": "Participant said teacher again.",
         "user_msg_excerpt": "as i said , teacher",
         "question_set": [
             {"text": "Besides students, who else would be affected by this system?", "status": "asked"},
             {"text": "Who would look at the system's predictions in day-to-day work?", "status": "asked"},
             {"text": "Whose decisions could change because the system is deployed?", "status": "pending"},
         ]},
    ]
    recent = [
        {"role": "user", "content": ARGUMENT},
        {"role": "assistant", "content": "Starting from what you wrote about privacy: Besides students, who else would be affected by this system?"},
        {"role": "user", "content": "teacher"},
        {"role": "assistant", "content": "Got it. Who in the university would use the predictions day to day?"},
        {"role": "user", "content": "as i said , teacher"},
        {"role": "assistant", "content": "Right. What everyday choice or action would teachers or staff make differently because of this system?"},
    ]
    # This is the exact phrasing from the log that bled into probe-3 territory.
    current = ("teacher will provide support to students, so that when they "
               "see the flag in system, they may treat students differently")

    verdict = classify(
        discussion_plan_subprobes=subprobes,
        manager_history=history,
        recent_turns=recent,
        current_msg=current,
        initial_argument=ARGUMENT,
    )
    print(f"manager turn_type   : {verdict.turn_type}")
    print(f"manager live_subprobe_id: {verdict.live_subprobe_id} (was 2; advanced={verdict.advanced_this_turn})")
    print(f"manager directive:\n{verdict.directive}\n")
    print("manager question_set for new probe:")
    for i, item in enumerate(verdict.question_set or []):
        print(f"  [{i}] ({item.get('status','?')}) {item.get('text','')}")
    print()

    # Manager should advance to probe 3 (expected_consequences).
    advanced_ok = verdict.turn_type == "transition-due" and verdict.live_subprobe_id >= 3

    qset = verdict.question_set or []
    first_pending = next((it["text"] for it in qset if it.get("status") == "pending"), "")

    # Look-back check: the first pending must not be a near-paraphrase of the
    # participant's CURRENT_MSG. Two heuristic gates:
    #  (1) tokens unique to the question (beyond stopwords) should NOT be a
    #      subset of CURRENT_MSG tokens — meaning the question introduces a
    #      fresh angle.
    #  (2) literal phrase overlap: forbid the question from containing
    #      "treat students differently" or "see the flag" verbatim — those
    #      are the participant's just-said words.
    STOP = {"the","a","an","to","of","in","on","at","for","by","with","and","or","but",
            "what","why","how","who","which","when","where","is","are","do","does","did",
            "you","they","them","their","this","that","these","those","be","been","being",
            "would","could","should","will","might","may","just","one","two","else","more",
            "after","before","over","under","also","like","as","if","then","than","there"}
    def tokenize(s):
        s = re.sub(r"[^a-zA-Z\s]", " ", s.lower())
        return [w for w in s.split() if w and w not in STOP and len(w) > 2]
    q_tokens = set(tokenize(first_pending))
    msg_tokens = set(tokenize(current))
    q_unique_to_msg = bool(q_tokens - msg_tokens)  # has at least one fresh non-stopword

    literal_overlap_phrases = (
        "treat students differently", "treat them differently",
        "see the flag", "see a flag", "see the prediction",
        "provide support",
    )
    literal_leak = [p for p in literal_overlap_phrases if p in first_pending.lower()]

    # An additional sanity check: the question should reasonably ask about
    # consequences beyond the immediate reaction (e.g. "first concrete change",
    # "over time", "downstream", "trajectory", "if wrong", "specifically").
    pivot_signal_words = ("first", "concrete", "specifically", "over time",
                          "trajectory", "downstream", "wrong", "instead",
                          "longer", "later", "outside", "missed",
                          "noticed", "trust", "interest", "grade", "experience")
    has_pivot_signal = any(w in first_pending.lower() for w in pivot_signal_words)

    checks = [
        (f"manager advanced to probe 3 (got {verdict.live_subprobe_id})", advanced_ok),
        (f"first pending exists (got {first_pending!r})", bool(first_pending)),
        (f"first pending has tokens beyond CURRENT_MSG (fresh angle)", q_unique_to_msg),
        (f"first pending has no literal leak of participant phrases (leaks: {literal_leak})",
         not literal_leak),
        (f"first pending has a pivot signal (one of {pivot_signal_words})",
         has_pivot_signal),
    ]
    return report("Scenario D", checks)


# ---------------------------------------------------------------------------
def main():
    banner("STEP 0: generate plan")
    arts = generate_session_artifacts(ARGUMENT)
    plan_text = arts.get("discussion_plan", "")
    subprobes = plan_parsing.parse_plan(plan_text)
    print(f"plan sub-probes: {len(subprobes)}; dominant: {arts.get('dominant_framework')}")
    for sp in subprobes:
        print(f"  id={sp['id']} component={sp['component']:<22}")
        print(f"      gap : {(sp.get('gap') or '')[:80]}")
        print(f"      hook: {(sp.get('hook') or '')[:80]}")

    missing = [sp["id"] for sp in subprobes if not sp.get("hook", "").strip()]
    if missing:
        print(f"WARNING: plan missing hook on {missing}; scenarios touching those may fail.")

    a = scenario_a_opening(subprobes, plan_text)
    b = scenario_b_path_a_transition(subprobes, plan_text)
    c = scenario_c_path_b_transition(subprobes, plan_text)
    d = scenario_d_lookback_pivot(subprobes, plan_text)

    banner(f"OVERALL: A={'PASS' if a else 'FAIL'}  "
           f"B={'PASS' if b else 'FAIL'}  "
           f"C={'PASS' if c else 'FAIL'}  "
           f"D={'PASS' if d else 'FAIL'}")
    return a and b and c and d


if __name__ == "__main__":
    sys.exit(0 if main() else 1)