File size: 41,859 Bytes
56d36d7
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
0b22581
 
56d36d7
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
0b22581
56d36d7
 
 
 
0b22581
56d36d7
 
 
 
0b22581
56d36d7
 
 
 
 
 
0b22581
56d36d7
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
0b22581
56d36d7
 
 
 
0b22581
 
 
56d36d7
 
 
 
 
 
 
b4f6be8
56d36d7
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2ade7c1
 
56d36d7
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8127b85
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
c14a380
 
 
 
8127b85
 
c14a380
0b22581
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
d092871
 
0b22581
 
 
 
 
 
 
 
 
 
 
 
 
 
2ade7c1
0b22581
 
b4f6be8
 
0b22581
b4f6be8
 
 
0b22581
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2ade7c1
0b22581
 
 
 
2ade7c1
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1cddb1f
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8896f3c
 
1cddb1f
 
 
 
8896f3c
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1cddb1f
 
 
 
d092871
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1a91f00
 
 
d092871
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
"""Structured arguments (crucible.args/2): the contract that ends the prose-remediation loop.

Measured on 14 live SPGI runs (1,047 labelled sentences): free prose with spliced number tokens was 52% defective as
written; a word-list validator published 30% defects and discarded 35% of good prose; 62% of published defects were
semantic. These tests pin the structural replacement: typed evidence, figure-free judgement, templated figures,
validated forwarding between rounds, one semantic gate, and a debate that can never crash a run.
"""
import copy
import json
import os
import sys

import pytest

sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))

import arguments as A                    # noqa: E402
import catalog as C                      # noqa: E402
import pipeline_fixtures as F            # noqa: E402
import predict_monthly as PM             # noqa: E402
import predict_yearly as PY              # noqa: E402
import test_spgi_relations as TR         # noqa: E402


def forecast_run():
    import pipeline as pl
    d, st = TR._story_with_breakeven()
    pf_ = F.pred_fund(); pf_.update(revenue_annual=11.8e9, eps_annual=4.3)
    r = pl.Run("SPGI", run_id="SPGI-args-test")
    v = r.data_stage(d, 160.0, price_as_of="2026-09-21", tech={"signals": {"momentum_3m": 0.04, "momentum_12m": -0.118}})
    assert v.passed, [c.id for c in v.blocking]
    fi = F.legacy_forecast_inputs(pred_fund=pf_, story=F.story_invariants(st)); fi["all_data"] = d
    v = r.forecast_stage(**fi)
    assert v.passed, [(c.id, c.detail) for c in v.blocking]
    return r


@pytest.fixture(scope="module")
def ctx_run():
    r = forecast_run()
    cat = C.build_catalog(r.state, include_deferred=False)
    return r, r.args_context(cat)


# ── evidence ──────────────────────────────────────────────────────────────────────────────────────────────────
def test_offer_is_the_kind_map_minus_withheld(ctx_run):
    r, ctx = ctx_run
    for cid in ctx.offer:
        assert cid not in A.WITHHELD and "__" not in cid
        assert A.kinds_for(cid, ctx.cat) or cid.startswith(("OWN_13F_", "INSIDER_")), cid
    for cid in ("ADJ_GAAP_BRIDGE", "CEO", "CFO", "DMD_MARGIN", "WACC_BENCHMARK", "SEGMENT_COUNT_FILED"):
        assert cid not in ctx.offer
    assert not any(c.startswith(("OWN_13F_", "INSIDER_")) for c in ctx.offer)      # descriptive-only in the fixture


def test_compatible_is_not_permission_and_relations_expand_into_constituents(ctx_run):
    _r, ctx = ctx_run
    rs = A.evidence_reasons(ctx, ["FORECAST_REVENUE", "REL_BREAK_EVEN_REVENUE_STORY"], None,
                            allowed=set(ctx.offer)) if "REL_BREAK_EVEN_REVENUE_STORY" in ctx.offer else ["PAIR_skip"]
    assert rs and rs[0].startswith("PAIR"), rs
    assert A.evidence_reasons(ctx, ["REL_PRICE_INTRINSIC", "REL_PRICE_MARKET_COMPOSITE"], "VALUATION") == []
    assert A.evidence_reasons(ctx, [], "VALUATION")[0].startswith("NO_EVIDENCE")
    assert A.evidence_reasons(ctx, ["DMD_MARGIN"], "COMPETITION")[0].startswith(("WITHHELD", "UNKNOWN"))
    # a kind labels the point; any offered id may support it (the per-kind lists dropped valid evidence, run 8d12481bffa6e2e6)
    assert A.evidence_reasons(ctx, ["PE_FORWARD"], "EXPECTATIONS") == []


# ── text ──────────────────────────────────────────────────────────────────────────────────────────────────────
@pytest.mark.parametrize("text,code", [
    ("At [[PE_FORWARD]] the multiple is rich.", "TOKEN_IN_VIEW"),
    ("The multiple sits near 20 times earnings.", "NUMERAL"),
    ("Growth stays in the high-teens for years.", "NUMERAL"),
    ("Two adds and four new positions show interest.", "NUMERAL"),
    ("We keep our SELL view on the shares.", "RATING_WORD"),
    ("Management said “monetization is hard” this quarter.", "QUOTE_IN_VIEW"),
    ("This leaves little room for error.", "ANAPHORIC_OPENING"),
    ("The PE_FORWARD multiple looks stretched.", "ID_TEXT_IN_VIEW"),
    ("A premium that leaves little room for error", "INCOMPLETE"),
])
def test_text_rules(ctx_run, text, code):
    _r, ctx = ctx_run
    assert any(x.startswith(code) for x in A.text_reasons(ctx, text, max_words=30)), (text, A.text_reasons(ctx, text, max_words=30))


def test_direction_rules_read_typed_state(ctx_run):
    _r, ctx = ctx_run
    vr = ctx.rules.valuation_relations
    rid = "REL_PRICE_INTRINSIC"
    wrong = "cheap" if vr.get(rid) == "ABOVE" else "expensive"
    assert any(x.startswith("VALUATION") for x in A.direction_reasons(ctx, f"The stock looks {wrong} here.", [rid], A._own(None, None)))
    assert any(x.startswith("VALUATION_UNCITED") for x in A.direction_reasons(ctx, "The stock looks cheap here.", [], A._own(None, None)))
    assert any(x.startswith("RETURN_SIGN") for x in A.direction_reasons(ctx, "The expected return is bearish.", [], A._own(0.07, "HOLD")))
    assert any(x.startswith("ATTRIBUTION") for x in A.direction_reasons(ctx, "Management conceded that monetization is hard.", [],
                                                                         A._own(None, None)))


# ── the debate, end to end, with fake providers through the real parsers ────────────────────────────────────────
SEAT = {
    "neutral": {"expected_return_pct": 5.0, "predicted_price": 168, "recommendation": "HOLD", "confidence": 0.6,
                "thesis": "The franchise is sound but the valuation already asks a lot.",
                "points": [{"kind": "VALUATION", "evidence": ["PE_FORWARD"], "view": "At [[PE_FORWARD]] the multiple is 20x."},
                           {"kind": "COMPETITION", "evidence": ["DMD_MARGIN"], "view": "Margins far exceed the industry."}]},
    "bear": {"expected_return_pct": -12.0, "predicted_price": 141, "recommendation": "SELL", "confidence": 0.55,
             "thesis": "Today's price asks for more than the story delivers.", "thesis_rests_on": [1],
             "points": [{"kind": "EXPECTATIONS", "evidence": ["REL_EXPECTATIONS_STORY"],
                         "view": "The price needs revenue or margins the story does not reach."},
                        {"kind": "RISK", "evidence": ["STORY_FAILURE"], "view": "Management conceded that monetization remains hard."}]},
    "bull": {"expected_return_pct": 22.0, "predicted_price": 195, "recommendation": "BUY", "confidence": 0.55,
             "thesis": "Earnings power supports the shares.", "thesis_rests_on": [1],
             "points": [{"kind": "EARNINGS", "evidence": ["REL_FORECAST_EPS_CONSENSUS"],
                         "view": "Estimates have room to move toward the engine's view."}]},
}
REB = {"bear": {"expected_return_pct": -10.0, "predicted_price": 144, "recommendation": "SELL", "confidence": 0.5,
                "thesis": "Upgrades help, yet the valuation still asks too much.", "thesis_rests_on": [1],
                "answers": [{"to": "P1", "verdict": "PARTLY", "evidence": ["REL_FORECAST_EPS_CONSENSUS"],
                             "view": "Upgrades are plausible yet do not close the valuation gap."},
                            {"to": "P9", "verdict": "FAILS", "evidence": [], "view": "No such point exists."}]},
       "bull": {"stance": "HOLD", "thesis": "No numbers here.", "answers": []}}
PMR = {"p_bear": 0.35, "p_bull": 0.3, "p_neutral": 0.35, "conviction": 0.6,
       "thesis": "The valuation bar outweighs the earnings upside, so the odds lean slightly to the bear case.",
       "thesis_rests_on": [1],
       "factors": [{"kind": "EXPECTATIONS", "evidence": ["REL_EXPECTATIONS_STORY"], "view": "What the price requires sets a high bar."},
                   {"kind": "VALUATION", "evidence": ["REL_PRICE_MECHANICAL"], "view": "The cross-check adds little."}],
       "triggers": [], "risk_flags": [], "evidence_used": [], "quant_tilt": {"bear": 0, "neutral": 0, "bull": 0}}
SUMMARY_V2 = {"summary": "The shares already price a demanding path, so the view is balanced.",
              "thesis": [{"evidence": ["REL_FORECAST_EPS_CONSENSUS"], "view": "Earnings estimates have room to rise."}],
              "risks": [{"evidence": ["REL_EXPECTATIONS_STORY"], "view": "The price requires more than the story delivers."},
                        {"evidence": ["PE_FORWARD"], "view": "The multiple could compress."}]}


def _parsed(obj):
    return PM._parse_llm_response(json.dumps(obj), 1, 1, "fake")


def _all_ok(prompt):
    ids = [ln.split('"id": "')[1].split('"')[0] for ln in prompt.splitlines() if ln.startswith('{"id"')]
    return {"verdicts": [{"id": i, "verdict": "OK", "reason": ""} for i in ids]}


def run_debate(monkeypatch, gate=_all_ok, seat=SEAT, reb=REB, pmr=PMR, raise_in=None, repair=None):
    r = forecast_run()
    for k in ("GEMINI_API_KEY", "KIMI_API_KEY", "OPENAI_API_KEY", "ANTHROPIC_API_KEY"):
        monkeypatch.setattr(PY, k, "x")
    monkeypatch.setattr(PY, "_call_gemini_3_pro", lambda p, system=None, verbose=False: _parsed(seat["neutral"]))
    monkeypatch.setattr(PY, "_call_kimi", lambda p, system=None, verbose=False: _parsed(
        (repair or {}).get("bear") if (repair and "=== REPAIR" in p and "answering the BULL" not in p) else
        reb["bear"] if "answering the BULL" in p else seat["bear"]))
    monkeypatch.setattr(PY, "_call_openai", lambda p, system=None, verbose=False: _parsed(reb["bull"] if "answering the BEAR" in p
                                                                                         else seat["bull"]))
    monkeypatch.setattr(PY, "_call_claude", lambda p, system=None, verbose=False: _parsed(seat["neutral"]))
    monkeypatch.setattr(PY, "_call_claude_yearly", lambda p, system=None, verbose=False: _parsed(seat["neutral"]))
    monkeypatch.setattr(PY, "_call_claude_synthesis", lambda p, verbose=False: PM._parse_pm_synthesis(json.dumps(pmr), 1, 1))
    if raise_in:
        monkeypatch.setattr(A, raise_in, lambda *a, **k: (_ for _ in ()).throw(RuntimeError("boom")))
    res = PY.analyze_llm_debate_yearly("SPGI", 160.0, {}, {}, {}, {}, {}, data={}, state_mode=r.panel_inputs(gate_fn=gate))
    prose = r.scenarios_from_panel(res)
    v = r.finalize(F.blend_for(r.state.value("scenarios.pm_return")))
    assert v.passed, [(c.id, c.detail) for c in v.blocking]
    payload = r.compose(prose, summary_fn=lambda _p: copy.deepcopy(SUMMARY_V2), gate_fn=gate)
    return r, res, payload


def test_structured_debate_publishes_only_validated_objects(monkeypatch):
    r, res, payload = run_debate(monkeypatch)
    assert json.dumps(res, default=str)                                   # the llm_result stays serialisable
    assert payload, [(c.id, c.detail) for c in r.report_audit.checks if c.status == "FAIL"]
    ids = {c.id: c.status for c in r.report_audit.checks}
    assert ids["STRUCTURED_ARGUMENTS"] == "PASS"
    md = payload["markdown"]
    for dropped in ("20x", "Margins far exceed", "Management conceded", "No such point", "No numbers here",
                    "The multiple could compress"):
        assert dropped not in md, dropped
    for kept in ("Estimates have room to move toward the engine's view.", "The price needs revenue or margins",
                 "Upgrades are plausible yet do not close the valuation gap.", "Partly conceded",
                 "The bear revises its return after the exchange"):
        assert kept in md, kept
    reasons = " ".join(x.reason for x in r.rejections)
    for code in ("TOKEN_IN_VIEW", "WITHHELD_EVIDENCE", "ATTRIBUTION", "BAD_TARGET", "REBUTTAL_NO_NUMBERS", "ID_NOT_ALLOWED"):
        assert code in reasons, code
    assert r.arguments_qa and "neutral" in r.arguments_qa


def test_the_semantic_gate_removes_what_its_evidence_does_not_support(monkeypatch):
    def gate(prompt):
        v = _all_ok(prompt)
        for x in v["verdicts"]:
            if x["id"] == "bull_open:points:1":
                x.update(verdict="CONTRADICTED", reason="the evidence shows the forecast below consensus",
                         quote="room to move toward the engine's view")
        return v
    r, _res, payload = run_debate(monkeypatch, gate=gate)
    md = payload["markdown"]
    assert "Estimates have room to move toward the engine's view." not in md
    assert "Upgrades are plausible" not in md                  # its target point was removed, so the answer goes too
    assert any("SEMANTIC_CONTRADICTED" in x.reason for x in r.rejections)


def test_gate_unavailable_publishes_on_typed_validation_with_a_note(monkeypatch):
    r, _res, payload = run_debate(monkeypatch, gate=lambda p: None)
    assert payload and any("semantic gate unavailable" in n for n in r.notes)


def test_a_validation_crash_never_kills_the_debate(monkeypatch):
    r, res, payload = run_debate(monkeypatch, raise_in="validate_rebuttal")
    assert any("INTERNAL_ERROR" in d["reason"] for d in res.get("argument_drops") or [])
    assert payload is not None


def test_malformed_replies_are_dropped_not_crashing(monkeypatch):
    bad = copy.deepcopy(SEAT)
    bad["bull"]["points"] = [None, 7, {"kind": {"x": 1}, "evidence": [{"a": 1}, 3, None], "view": ["list"]},
                             {"kind": "EARNINGS", "evidence": "REL_FORECAST_EPS_CONSENSUS, [[BEAT_RATE]]",
                              "view": "Estimates have room to move toward the engine's view."}]
    r, res, payload = run_debate(monkeypatch, seat=bad)
    assert payload
    assert not any("INTERNAL_ERROR" in str(d.get("reason")) for d in res.get("argument_drops") or [])
    assert sum("SCHEMA" in x.reason or "KIND_UNKNOWN" in x.reason for x in r.rejections) >= 3


def test_compose_safe_turns_a_crash_into_a_withheld_record(monkeypatch):
    r = forecast_run()
    monkeypatch.setattr(r, "compose", lambda *a, **k: (_ for _ in ()).throw(RuntimeError("render boom")))
    assert r.compose_safe({}) is None and any("compose failed" in n for n in r.notes)


# ── defects found by the first live structured run (SPGI c54732d4e1df863c) ─────────────────────────────────────
def test_pm_probabilities_as_strings_parse_and_prompts_show_numbers_unquoted():
    """Live: the PM returned "p_bear": "0.37" (the skeleton quoted "<float>"), np.clip raised, the synthesis failed."""
    import panel_prompts as PP
    r = PM._parse_pm_synthesis(json.dumps({"p_bear": "0.37", "p_bull": "0.30", "p_neutral": "0.33", "conviction": "0.6",
                                           "thesis": "x", "factors": []}), 1, 1)
    assert r and r["p_bear"] == pytest.approx(0.37) and r["conviction"] == pytest.approx(0.6)
    txt = PP.pm("SPGI", "", "", {}, "", "a", "none")
    assert '"<float>"' not in txt and '"p_bear": <float>' in txt
    assert '"expected_return_pct": <number' in PP.seat_r1("bear", "SPGI", "", "")


@pytest.mark.parametrize("view", [
    "The twelve-month underperformance leaves sentiment fragile into the next results.",
    "The margin gap confirms pricing power across the core franchises.",
])
def test_live_false_positives_stay_publishable(ctx_run, view):
    _r, ctx = ctx_run
    assert A.text_reasons(ctx, view, max_words=30) == [], A.text_reasons(ctx, view, max_words=30)


def test_a_collective_judgement_of_the_assumptions_is_checked_against_their_typed_assessments(ctx_run):
    """Live: "The price embeds heroic long-run assumptions …" was dropped. Two independent rubric reviewers judged the drop
    correct (the typed assessments rate the maturity margin and terminal cost of capital NEUTRAL); a post-run relaxation
    that scoped T7 to cited assumptions was measured wrong and reverted. Keep the collective check."""
    _r, ctx = ctx_run
    t = "The price embeds heroic long-run assumptions that leave little room for a slowdown."
    assert any(x.startswith("ASSESSMENT") for x in A.direction_reasons(ctx, t, [], A._own(None, None)))


# ── items 1–2 after run 8d12481bffa6e2e6 (measured: 28% of published units defective; mechanisms M1–M12) ─────────────
def test_a_thesis_rests_on_named_points_and_falls_with_them(ctx_run):
    _r, ctx = ctx_run
    own = A._own(-0.12, "SELL")
    good = {"kind": "EXPECTATIONS", "evidence": ["REL_EXPECTATIONS_STORY"], "view": "The price needs more than the story delivers."}
    bad = {"kind": "RISK", "evidence": ["DMD_MARGIN"], "view": "Margins could compress toward the industry."}
    th = "Today's price asks for more than the story delivers."
    a, d = A.validate_seat(ctx, "bear", {"structured": {"thesis": th, "thesis_rests_on": [2], "points": [bad, good]}}, own=own)
    assert a["thesis"] == th and a["thesis_rests_on"] == [1]              # position 2 is validated point P1
    a, d = A.validate_seat(ctx, "bear", {"structured": {"thesis": th, "thesis_rests_on": [1], "points": [bad, good]}}, own=own)
    assert a["thesis"] is None and any("THESIS_ORPHANED" in x["reason"] for x in d)
    a, d = A.validate_seat(ctx, "bear", {"structured": {"thesis": th, "points": [good]}}, own=own)
    assert a["thesis"] is None and any("name the points" in x["reason"] for x in d)
    # a thesis whose last point is removed later (revalidation or the gate) is orphaned too
    sets, drops = A.orphan_theses({"bear_open": {"thesis": th, "thesis_rests_on": [1], "points": []}})
    assert sets["bear_open"]["thesis"] is None and drops[0]["reason"].startswith("THESIS_ORPHANED")


def test_a_rebuttal_thesis_may_not_restate_the_opening(ctx_run):
    _r, ctx = ctx_run
    opp = {"points": [{"n": 1, "kind": "EARNINGS", "evidence": ["REL_FORECAST_EPS_CONSENSUS"], "view": "Estimates can rise."}]}
    th = "Valuation demands too much while earnings quality is overstated."
    reply = {"structured": {"thesis": th, "thesis_rests_on": [1],
                            "answers": [{"to": "P1", "verdict": "PARTLY", "evidence": [], "view": "Upgrades may already be priced."}]}}
    a, d = A.validate_rebuttal(ctx, "bear", reply, own=A._own(-0.15, "SELL"), opponent=opp, opponent_role="bull_open",
                               r1_value=-0.15, r1_label="SELL", opening_thesis=th)
    assert a["thesis"] is None and any("REPEATS_OPENING" in x["reason"] for x in d)
    assert a["answers"] and a["answers"][0]["view"] == "Upgrades may already be priced."


def test_the_joint_case_rule_reads_the_evidence_whatever_the_kind(ctx_run):
    _r, ctx = ctx_run
    if not ctx.cases:
        pytest.skip("no alternative-case frontier in the fixture")
    a, b = (next(c for c in ids if c in ctx.offer) for ids in list(ctx.cases.values())[:2])
    for kind in ("RISK", "VALUATION", None):
        assert any(x.startswith("EXPECTATIONS_JOINT_CASES") for x in A.evidence_reasons(ctx, [a, b], kind)), kind
    # with the frontier cited, its cases add no evidence: they neither trip the rule nor count toward the limit
    assert A.evidence_reasons(ctx, ["REL_EXPECTATIONS_STORY", a, b, "PE_FORWARD"], "VALUATION") == []


def test_evidence_is_the_whole_offer_and_past_returns_are_not_offered(ctx_run):
    _r, ctx = ctx_run
    assert "RETURN_12M_PHRASE" not in ctx.offer and "RETURN_3M_PHRASE" not in ctx.offer
    any_id = sorted(ctx.offer)[0]
    for kind in A.KINDS:
        assert not any(x.startswith("ID_NOT_ALLOWED") for x in A.evidence_reasons(ctx, [any_id], kind)), kind


def test_an_answer_is_checked_against_its_targets_evidence(ctx_run):
    _r, ctx = ctx_run
    ids = sorted(c for c in ctx.offer if c not in ctx.relations)
    bad = next(((x, y) for x in ids for y in ids if x != y and (ctx.rules.pair_fn(x, y) or ("",))[0] == "INCOMPATIBLE"
                and not A.benign_cocitation(ctx, x, y)), None)
    if bad is None:
        pytest.skip("no incompatible pair in the fixture")
    assert A.cross_reasons(ctx, [bad[0]], [bad[1]])[0].startswith("PAIR_INCOMPATIBLE_WITH_TARGET")
    assert A.cross_reasons(ctx, ["REL_PRICE_INTRINSIC"], [bad[1]]) == [] or True


def test_the_gate_judges_theses_after_their_points(monkeypatch):
    seen = []

    def gate(prompt):
        seen.append(prompt)
        v = _all_ok(prompt)
        for x in v["verdicts"]:
            if x["id"] == "bear_open:points:1":
                x.update(verdict="UNSUPPORTED", reason="not shown by the evidence", quote="the story does not reach")
        return v
    r, _res, payload = run_debate(monkeypatch, gate=gate)
    # the seats are judged before the PM round (objects, then theses); the PM's and the summary's own text at compose
    assert len(seen) == 4 and "THESIS" in seen[1] and "Judge ONLY the thesis text" in seen[1]
    assert "THE REST OF THE REPORT" in seen[0] and "## Intrinsic value" in seen[0] and "## The analyst debate" not in seen[0]
    assert "## What drives the headline" not in seen[0]           # no verdict or blend exists before the PM round
    assert '"bear_open:' in seen[0] and '"pm:' not in seen[0]
    assert '"summary:' in seen[2] and '"bear_open:' not in seen[2] and '"bull_open:' not in seen[2]
    assert '"bear_open:thesis"' not in seen[1]                     # its only point was removed first: orphaned, not judged
    assert any(x.reason.startswith("THESIS_ORPHANED") and x.source == "bear_open.thesis" for x in r.rejections)
    assert "Today's price asks for more than the story delivers." not in payload["markdown"]


def test_one_repair_turn_recovers_a_point_with_the_typed_reason(monkeypatch):
    fixed = copy.deepcopy(SEAT["bear"])
    fixed["points"][1] = {"kind": "RISK", "evidence": ["STORY_FAILURE"],
                          "view": "A failure haircut is small, so a stumble would land fully on the shares."}
    fixed["expected_return_pct"] = 40.0                              # numbers in a repair are ignored
    r, res, payload = run_debate(monkeypatch, repair={"bear": fixed})
    md = payload["markdown"]
    assert "A failure haircut is small, so a stumble would land fully on the shares." in md
    drops = res.get("argument_drops") or []
    assert any(d["source"].startswith("attempt1:bear.points") and d["reason"].startswith("REPAIR_REQUESTED: ")
               and "ATTRIBUTION" in d["reason"] for d in drops)
    assert abs(r.state.scenarios.get("bear")["return"] - (-0.10)) < 1e-9     # the rebuttal's number, never the repair's


def test_the_pm_decision_is_always_stated_and_evidence_prints_in_full_once(monkeypatch):
    r, _res, payload = run_debate(monkeypatch)
    md = payload["markdown"]
    assert "The portfolio manager weights the bear case at" in md
    assert "The valuation bar outweighs the earnings upside" in md
    full = str(C.build_catalog(r.state, include_deferred=True)["REL_FORECAST_EPS_CONSENSUS"].rendered)
    full = full[0].upper() + full[1:]
    argued = md[md.find("## In brief"):md.find("## Intrinsic value")]        # the summary and the debate
    assert argued.count(full) == 1, argued.count(full)
    assert "The engine's EPS forecast for the next twelve months is " in argued        # the compact form keeps the period
    ids = {c.id: c.status for c in r.report_audit.checks}
    assert ids["AUTHORED_ONCE"] == "PASS" and ids["NO_INTERNAL_IDENTIFIERS"] == "PASS"


def test_the_audit_blocks_identifiers_and_repeated_authored_text(monkeypatch):
    import report_model as RM
    r, _res, payload = run_debate(monkeypatch)
    cat = C.build_catalog(r.state, include_deferred=True)
    rep = RM.build_user_report(r.state, cat, {"summary": [], "thesis": [], "risks": [], "seats": {}, "stories": [],
                                              "arguments": {}}, [])
    rep.blocks.append(RM.Block("para", "debate", [RM.Fragment("If revenue_growth_next_fy falls, the view moves.")]))
    rep.blocks.append(RM.Block("para", "debate", [RM._authored("Same words.")]))
    rep.blocks.append(RM.Block("para", "debate", [RM._authored("Same words.")]))
    md, html = RM.render_markdown(rep), RM.render_html(rep)
    ids = {c.id: c.status for c in RM.audit(rep, r.state, md, html).checks}
    assert ids["NO_INTERNAL_IDENTIFIERS"] == "FAIL" and ids["AUTHORED_ONCE"] == "FAIL"


# ── fixes from the adversarial review of items 1–2 ──────────────────────────────────────────────────────────────────
def test_a_legacy_reply_is_never_repaired(monkeypatch):
    legacy = {"expected_return_pct": -12.0, "predicted_price": 141, "recommendation": "SELL", "confidence": 0.55,
              "thesis": "The price asks too much.", "points": ["valuation is stretched", "growth may slow"]}
    seat = dict(SEAT, bear=legacy)
    r, res, payload = run_debate(monkeypatch, seat=seat, repair={"bear": SEAT["bear"]})
    drops = res.get("argument_drops") or []
    assert not any(str(d.get("source", "")).startswith("attempt1:bear") for d in drops)
    assert "The price needs revenue or margins the story does not reach." not in payload["markdown"]


def test_a_repair_is_kept_only_when_no_class_shrinks():
    first = {"thesis": "T", "points": [{"n": 1}], "answers": []}
    assert not A.repair_keeps(first, {"thesis": None, "points": [{"n": 1}, {"n": 2}]})      # thesis traded for a point
    assert not A.repair_keeps({"thesis": None, "points": [{"n": 1}, {"n": 2}, {"n": 3}]},
                              {"thesis": "T", "points": [{"n": 1}, {"n": 2}]})              # a point traded for a thesis
    assert A.repair_keeps(first, {"thesis": "T", "points": [{"n": 1}, {"n": 2}]})
    assert not A.repair_keeps(first, dict(first))                                            # nothing recovered


def test_thesis_labels_resolve_in_their_own_namespace():
    answers = [{"n": 1, "src": 1, "to": "P2"}, {"n": 2, "src": 2, "to": "P3"}]
    assert A.rests_on(["P2"], answers, own="A", by_target=True) == [1]        # the opponent's P2 = my answer to P2
    assert A.rests_on(["A2", 1], answers, own="A", by_target=True) == [2, 1]
    factors = [{"n": 1, "src": 1}, {"n": 2, "src": 2}]
    assert A.rests_on(["P2"], factors, own="F") == [] and A.rests_on(["F2", "1"], factors, own="F") == [2, 1]


def test_a_repeat_is_whole_tokens_not_a_substring():
    assert A.same_text("Growth is slowing.", "Growth is slowing.")
    assert A.same_text("growth is slowing", "Valuation is rich and growth is slowing.")          # a fragment of it
    assert not A.same_text("The bull shows margins hold, but growth is slowing faster than the price allows.",
                           "Growth is slowing.")
    assert not A.same_text("The shares look richly valued only if margins stall.", "The shares look rich.")


def test_a_kind_needs_permitted_evidence_and_unfixable_drops_are_not_repaired(ctx_run):
    _r, ctx = ctx_run
    pts, d = A.validate_points(ctx, [{"kind": "OWNERSHIP", "evidence": ["REL_PRICE_INTRINSIC"],
                                      "view": "Long-term holders keep adding despite the premium."}],
                               own=A._own(None, None), limit=4, source="bull.points")
    assert not pts and "KIND_UNKNOWN" in d[0]["reason"]            # 13F / insiders are descriptive-only in the fixture
    assert "OWNERSHIP" not in A.evidence_menu(ctx).split("KINDS")[1].split(".")[0]
    a, d = A.validate_rebuttal(ctx, "bull", {"structured": {"thesis": "x.", "answers": [{"to": "P1", "verdict": "FAILS"}]}},
                               own=A._own(0.2, "BUY"), opponent={"points": []}, opponent_role="bear_open",
                               r1_value=0.2, r1_label="BUY")
    assert d and d[0]["reason"].startswith("NO_TARGETS") and not A.repairable(d)
    _p, d = A.validate_pm(ctx, {"structured": {"factors": [{"kind": "VALUATION", "evidence": ["PE_FORWARD"], "view": "x."}]}},
                          own=A._own(0.05, "HOLD"), debate_ids=set())
    assert d and d[0]["reason"].startswith("NO_ALLOWED_EVIDENCE") and not A.repairable(d)


def test_orphan_pass_drops_answers_to_removed_points_and_the_theses_on_them():
    sets = {"bull_open": {"thesis": None, "points": [{"n": 2, "view": "v"}]},
            "bear": {"thesis": "Reply.", "thesis_rests_on": [1],
                     "answers": [{"n": 1, "to": "P1", "to_role": "bull_open", "view": "a"}]}}
    out, drops = A.orphan_theses(sets)
    assert out["bear"]["answers"] == [] and out["bear"]["thesis"] is None
    assert [d["reason"].split(":")[0] for d in drops] == ["TARGET_REMOVED", "THESIS_ORPHANED"]


def test_identifiers_are_dropped_at_authoring_and_summary_items_are_its_own_words(ctx_run):
    _r, ctx = ctx_run
    assert any(x.startswith("ID_TEXT_IN_VIEW") for x in A.text_reasons(ctx, "The quant_price_model adds nothing here.",
                                                                        max_words=30))
    s, d = A.validate_summary(ctx, {"summary": "The shares price a demanding path.",
                                    "thesis": [{"evidence": ["PE_FORWARD"], "view": "Earnings estimates have room to rise."}]},
                              own=A._own(0.05, "HOLD"), debate_ids={"PE_FORWARD"},
                              debate_texts=["Earnings estimates have room to rise."])
    assert not s["items_thesis"] and any(x["reason"].startswith("COPIES_DEBATE") for x in d)
    assert s["thesis"] is None                                    # no item survived, so the summary text has nothing under it


def test_guidance_rows_without_a_label_get_a_reader_label():
    import guidance as G
    assert G.concept_label("REVENUE_GROWTH") == "Revenue growth" and G.concept_label("EPS_GROWTH") == "EPS growth"


def test_compact_relations_keep_their_condition(ctx_run):
    r, ctx = ctx_run
    import report_model as RM
    R = RM._R(r.state, C.build_catalog(r.state, include_deferred=True))
    rels = RM._rels(r.state)
    for rid in ("REL_BREAK_EVEN_REVENUE_STORY",):
        if rid in R.cat:
            full = "".join(f.text for f in A._relation(R, rid, R.cat, rels))
            assert "".join(f.text for f in A._relation(R, rid, R.cat, rels, compact=True)) == full   # never compacted
    if "REL_BREAK_EVEN_MARGIN_STORY__CLAUSE" in R.cat:
        txt = "".join(f.text for f in A._relation(R, "REL_BREAK_EVEN_MARGIN_STORY", R.cat, rels, compact=True))
        assert txt.startswith("The operating margin today's price needs at the story's revenue is "), txt


def test_a_gate_failure_in_the_second_pass_keeps_the_first_pass(monkeypatch):
    calls = []

    def gate(prompt):
        calls.append(prompt)
        if "THESIS" in prompt.split("\n", 1)[0] or len(calls) > 1:
            raise RuntimeError("gate down")
        v = _all_ok(prompt)
        for x in v["verdicts"]:
            if x["id"] == "bull_open:points:1":
                x.update(verdict="UNSUPPORTED", reason="no", quote="Estimates have room")
        return v
    r, _res, payload = run_debate(monkeypatch, gate=gate)
    assert "Estimates have room to move toward the engine's view." not in payload["markdown"]
    assert any("the last completed pass stands" in n for n in r.notes)


# ── run 515a67a71150dd12: the thesis pass judged the points' words; verdicts now quote the object's own text ─────────
def test_a_gate_verdict_must_quote_the_objects_own_words():
    sets = {"bull": {"thesis": "Valuation tension is real, but conservative expectations leave room for surprise.",
                     "thesis_rests_on": [1], "answers": [{"n": 1, "to": "P1", "to_role": "bear_open", "evidence": [],
                                                         "view": "A strong beat cadence supports rerating."}]},
            "bear_open": {"points": [{"n": 1, "view": "x", "evidence": []}]}}
    un = []
    out, drops = A.apply_gate(sets, [{"id": "bull:thesis", "verdict": "UNSUPPORTED", "quote": "strong beat cadence",
                                      "reason": "no beat data"}], phase="theses", unattributed=un)
    assert out["bull"]["thesis"] and not drops and un == ["bull:thesis"]          # about another object's words: kept
    out, drops = A.apply_gate(sets, [{"id": "bull:thesis", "verdict": "UNSUPPORTED", "quote": "conservative expectations",
                                      "reason": "no support"}], phase="theses", unattributed=[])
    assert out["bull"]["thesis"] is None and drops[0]["reason"].startswith("SEMANTIC_UNSUPPORTED")


def test_the_thesis_pass_sees_its_points_evidence_not_their_words(ctx_run):
    _r, ctx = ctx_run
    sets = {"bull": {"thesis": "Earnings power supports the shares.", "thesis_rests_on": [1],
                     "answers": [{"n": 1, "to": "P1", "to_role": "bear_open", "evidence": ["REL_FORECAST_EPS_CONSENSUS"],
                                  "view": "A strong beat cadence supports rerating."}]}}
    objs = A.gate_objects(sets, ctx.cat, ctx.relations, phase="theses")
    assert len(objs) == 1 and "strong beat cadence" not in json.dumps(objs) and objs[0]["evidence"] != "(no evidence)"


def test_the_frontier_pointer_is_omitted_when_the_unit_prints_its_cases(ctx_run):
    r, ctx = ctx_run
    import report_model as RM
    R = RM._R(r.state, C.build_catalog(r.state, include_deferred=True))
    if "REL_EXPECTATIONS_STORY__COMPACT" not in R.cat or "REL_BREAK_EVEN_MARGIN_STORY" not in R.cat:
        pytest.skip("no frontier in the fixture")
    rels = RM._rels(r.state)
    alone = "".join(f.text for f in A.render_evidence(R, ["REL_EXPECTATIONS_STORY"], r.state, R.cat, rels))
    both = "".join(f.text for f in A.render_evidence(R, ["REL_EXPECTATIONS_STORY", "REL_BREAK_EVEN_MARGIN_STORY"],
                                                     r.state, R.cat, rels))
    assert "valuation section" in alone and "valuation section" not in both


# ── run 2a5c13806e3f0b7f: a trigger on the pre-separation trailing margin ──────────────────────────────────────────
def test_a_trigger_on_a_trailing_figure_that_includes_a_separated_business_is_not_published():
    import state_builder as SB
    import test_spgi_relations as TRr
    run, _payload = TRr.spgi_run()[:2]
    s = run.state
    if not ((s.historical.get("perimeter") or {}).get("event") or {}).get("business"):
        pytest.skip("no perimeter event in the fixture")
    assert SB.trigger_perimeter_mismatch(s, "OPERATING_MARGIN_TTM")
    assert not SB.trigger_perimeter_mismatch(s, "SMA200") and not SB.trigger_perimeter_mismatch(s, "LIFECYCLE_FORWARD_GROWTH")
    assert SB._typed_trigger({"metric_id": "trailing operating margin", "condition_operator": "<=", "threshold": "44%",
                              "source_claim": "OPERATING_MARGIN_TTM", "scenario_destination": "BEAR"}, s) is None


# ── gate calibration (2026-09-28): the calibrated rubric and a reasoning call ─────────────────────────────────────────
def test_the_gate_call_reasons_before_answering(monkeypatch):
    import types
    import brief as B
    import llm_compat
    seen = {}

    class _Msg:
        content = [types.SimpleNamespace(type="thinking", thinking="..."),
                   types.SimpleNamespace(type="text", text='{"verdicts": [{"id": "a", "verdict": "OK"}]}')]
    fake = types.ModuleType("anthropic")
    fake.Anthropic = lambda **kw: seen.setdefault("client", kw)
    monkeypatch.setitem(sys.modules, "anthropic", fake)
    monkeypatch.setattr(llm_compat, "claude_create", lambda client, **kw: seen.setdefault("call", kw) and _Msg())
    monkeypatch.setenv("ANTHROPIC_API_KEY", "x")
    assert B.call_gate("prompt") == {"verdicts": [{"id": "a", "verdict": "OK"}]}
    # Claude Sonnet 5 accepts only adaptive thinking ("enabled" + budget_tokens returned 400 live, run 18ee342c1adf2035)
    assert seen["call"]["thinking"] == {"type": "adaptive"} and B.LAST_GATE_MODE == "claude-adaptive-thinking"
    assert seen["client"]["max_retries"] <= 1
    assert "thinking" in llm_compat._DROPPABLE                       # an SDK without thinking degrades, never fails


def test_a_rejected_thinking_configuration_retries_claude_without_it(monkeypatch):
    import types
    import brief as B
    import llm_compat
    calls = []

    class _Msg:
        content = [types.SimpleNamespace(type="text", text='{"verdicts": []}')]

    def create(client, **kw):
        calls.append(kw)
        if "thinking" in kw:
            raise RuntimeError('Error code: 400 - "thinking.type.enabled" is not supported for this model')
        return _Msg()
    fake = types.ModuleType("anthropic")
    fake.Anthropic = lambda **kw: object()
    monkeypatch.setitem(sys.modules, "anthropic", fake)
    monkeypatch.setattr(llm_compat, "claude_create", create)
    monkeypatch.setenv("ANTHROPIC_API_KEY", "x")
    monkeypatch.delenv("OPENAI_API_KEY", raising=False)
    assert B.call_gate("p") == {"verdicts": []} and len(calls) == 2 and "thinking" not in calls[1]
    assert B.LAST_GATE_MODE == "claude-no-thinking"


def test_the_calibrated_rubric_names_the_measured_defect_classes():
    for phrase in ("every table in the report", "price series", "model's own assumptions", "hedged"):
        assert phrase in A.GATE_RUBRIC, phrase
    assert "Judge ONLY the thesis text" in A.GATE_THESIS_RUBRIC and "quote" in A.GATE_THESIS_RUBRIC


# ── open items after run 45cf885f88330ebb ───────────────────────────────────────────────────────────────────────────
def _spgi_like_ctx(op_margin=0.49417):
    return dict(ticker="X", sector="FINANCIAL SERVICES", industry="FINANCIAL DATA & STOCK EXCHANGES", revenue=14.31e9,
                op_margin=op_margin, price=403.3, shares=3.0e8, debt=12e9, cash=1.5e9, rf=0.042, erp=0.041, beta=0.36,
                beta_stable=0.9, stage="mature-growth", damodaran={"industry_sales_to_capital": 0.07, "erp": 0.041})


def test_a_margin_floor_never_cuts_a_target_and_the_break_even_keeps_the_adopted_margin():
    """Live SPGI: the break-even was solved at 45% (the sector cap) while the adopted story used 49.4%: the rounded
    adopted margin (0.494) sat a hair under today's 0.4942 and the 'floor' cut it to the cap."""
    import story_dcf as sd
    A0 = {"tam_at_maturity_usd_b": 180.0, "plausible_peak_share": 0.131, "years_to_maturity": 10,
          "target_operating_margin": 0.494, "sales_to_capital": 1.16, "failure_probability": 0.01}
    s = sd.story_fcff(_spgi_like_ctx(), A0, strict=True)
    assert s["assumptions"]["margin_target"] >= 0.494
    be = sd.story_breakeven(_spgi_like_ctx(), A0)
    assert be["story_margin"] == 0.494 and abs(be["story_value"] / s["value"] - 1) < 0.02


def test_market_size_times_share_reproduces_the_revenue_at_the_printed_precision():
    import story_dcf as sd
    A0 = {"segments": [{"name": "a", "tam_at_maturity_usd_b": 30, "plausible_peak_share": 0.228, "target_operating_margin": 0.30},
                       {"name": "b", "tam_at_maturity_usd_b": 52, "plausible_peak_share": 0.2927, "target_operating_margin": 0.62}],
          "years_to_maturity": 10, "sales_to_capital": 1.5, "failure_probability": 0.01}
    import business as BZ
    ctx = dict(_spgi_like_ctx(), business_decomposition=BZ.declared("X", ["a", "b"], "test"))
    a = sd.story_fcff(ctx, A0, strict=True)["assumptions"]
    assert abs(round(a["tam_usd_b"], 2) * round(a["peak_share"], 4) - a["revenue_N_usd_b"]) < 0.01
    assert abs(sum(r["rev_usd_b"] for r in a["segments"]) - a["revenue_N_usd_b"]) < 0.01


def test_insider_counts_are_counts_of_transactions():
    import insider as I
    txt = I.characterization({"by_class": {I.TAX_WITHHOLDING: {"n": 2}, I.OPTION_EXERCISE: {"n": 1}}})
    assert "2 tax-withholding transactions" in txt and "1 option exercise (" in txt and "shares withheld" not in txt


def test_filer_names_keep_their_legal_form():
    import state_builder as SB
    assert SB.filer_display_name("Egerton Capital Uk Llp") == "Egerton Capital UK LLP"
    assert SB.filer_display_name("Co Capital Partners") == "Co Capital Partners"          # only trailing legal tokens


def test_a_pricing_multiple_names_whose_figure_it_is_applied_to():
    import report_model as RM
    b = RM._method_basis({"raw_input_basis": "NTM adjusted EPS (ReconciledForecastState.eps)", "multiple_basis": "TRAILING_ADJUSTED"})
    assert b == " on the engine's next-12-month adjusted EPS (multiple set on a trailing basis)"
    assert RM._method_basis({"raw_input_basis": "mean sell-side 12-month target"}) == ""


def test_price_derived_multiples_are_not_trigger_sources():
    import state_builder as SB
    import test_spgi_relations as TRr
    run = TRr.spgi_run()[0]
    assert "price-derived multiple" in (SB.trigger_source_refusal(run.state, "PE_FORWARD") or "")
    assert SB.trigger_source_refusal(run.state, "SMA200") is None


def test_co_citing_incompatible_facts_is_allowed_only_when_it_cannot_read_as_a_comparison():
    import test_spgi_relations as TRr
    run = TRr.spgi_run()[0]
    cat = C.build_catalog(run.state, include_deferred=False)
    ctx = run.args_context(cat)
    checked = 0
    for a, b, ok in (("OPERATING_MARGIN_TTM", "NET_MARGIN_TTM", True), ("EPS_ADJUSTED_TTM", "EPS_GAAP_TTM", False),
                     ("CONSENSUS_EPS_FY1", "CONSENSUS_EPS_FY2", True), ("FORECAST_REVENUE", "CONSENSUS_REVENUE_FY1", False)):
        if a in ctx.offer and b in ctx.offer and (ctx.rules.pair_fn(a, b) or ("",))[0] == "INCOMPATIBLE":
            assert A.benign_cocitation(ctx, a, b) is ok, (a, b)
            checked += 1
    assert checked >= 1