"""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 ""), 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 '""' not in txt and '"p_bear": ' in txt assert '"expected_return_pct": 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