crucible / engine /arguments.py
s-ttp's picture
BSX root causes: token-safe segment keywords + parent-sector compatibility gate; growth label; semantic gate before the PM
b4f6be8 verified
Raw History Blame Contribute Delete
77.9 kB
"""Structured arguments (contract ``crucible.args/2``): authors choose EVIDENCE, templates render every figure.
Why this exists (measured on 14 live SPGI runs, 1,047 labelled sentences): when authors wrote free English with
number tokens spliced in, 52% of what they wrote was defective, a word-list validator published 30% defects and threw
away 35% of good prose, and 62% of the published defects were semantic — no rule over wording could decide them. The
loop closed only by changing what authors are asked to write.
The contract:
- an author returns typed objects: ``{"kind", "evidence": [claim ids], "view": "<figure-free judgement>"}``, a
figure-free ``thesis``, rebuttal ``answers`` that point at the opponent's VALIDATED points, PM ``factors``;
- every figure, comparison and direction in the report is rendered by templates from the cited typed claims;
- validation is typed: ids offered and allowed for the kind, pair compatibility, figure-free text, rating and return
direction, assessments and valuation relations of the point's OWN evidence, quote lineage by speaker role;
- what a typed check cannot decide (does the judgement follow from its evidence?) goes to one semantic gate
(``gate_prompt`` / ``apply_gate``) with the reviewers' rubric, never to a word list.
Nothing is rewritten: an object that fails is dropped with its reason codes. Parsing applies only the recorded,
meaning-preserving normalizations in ``_normalize_*`` (id spelling, kind and verdict aliases).
"""
from __future__ import annotations
import re
from dataclasses import dataclass, field
import catalog as C
import evidence as EV
import prose as P
SCHEMA = "crucible.args/2"
KINDS = ("OUTLOOK", "EARNINGS", "COMPETITION", "RISK", "MANAGEMENT", "VALUATION", "EXPECTATIONS", "OWNERSHIP")
KIND_LABEL = {"OUTLOOK": "Outlook", "EARNINGS": "Earnings", "COMPETITION": "Competition", "RISK": "Risk",
"MANAGEMENT": "Management", "VALUATION": "Valuation", "EXPECTATIONS": "What the price requires",
"OWNERSHIP": "Ownership"}
VERDICTS = ("FAILS", "PARTLY", "HOLDS")
VERDICT_CHIP = {"FAILS": "Disputed", "PARTLY": "Partly conceded", "HOLDS": "Conceded"}
LIMITS = {"view": 30, "thesis": 35, "pm_thesis": 50, "rebuttal_thesis": 35, "summary": 45, "item": 22,
"points": 4, "answers": 3, "factors": 4, "items": 3, "evidence": 3, "answer_evidence": 3}
# ── KIND_EVIDENCE: explicit and fail-closed (an offered id outside every kind is not offered) ───────────────────
_STORY_ASSUMPTIONS = ("STORY_TAM", "STORY_SHARE", "STORY_REVENUE_MATURITY", "STORY_YEARS", "STORY_MARGIN", "STORY_WACC",
"STORY_WACC_STABLE", "STORY_TERMINAL_GROWTH", "STORY_FAILURE", "STORY_START_MARGIN",
"STORY_START_REVENUE", "STORY_MARGIN_CHANGE", "STORY_S2C")
_BREAK_EVEN = ("REL_BREAK_EVEN_REVENUE_STORY", "REL_BREAK_EVEN_MARGIN_STORY", "REL_BREAK_EVEN_REVENUE_GROWTH")
_TRENDS = ("TREND_REVENUE", "TREND_REVENUE_GROWTH", "TREND_OPERATING_MARGIN", "TREND_EPS")
KIND_IDS = {
"OUTLOOK": {"FORECAST_REVENUE", "FORECAST_REVENUE_GROWTH", "REVENUE_TTM", "CONSENSUS_REVENUE_FY1", "CONSENSUS_REVENUE_FY2",
"CONSENSUS_REVENUE_NTM", "REL_ENGINE_REVENUE_CONSENSUS", "LIFECYCLE_STAGE", "LIFECYCLE_FORWARD_GROWTH",
"TREND_REVENUE", "TREND_REVENUE_GROWTH"},
"EARNINGS": {"OPERATING_MARGIN_TTM", "NET_MARGIN_TTM", "EPS_ADJUSTED_TTM", "EPS_GAAP_TTM", "FORECAST_EPS",
"FORECAST_EPS_GROWTH", "FORECAST_OPERATING_MARGIN", "FORECAST_REVENUE_GROWTH", "CONSENSUS_EPS_NTM",
"CONSENSUS_EPS_FY1", "CONSENSUS_EPS_FY2", "REL_FORECAST_EPS_CONSENSUS", "REL_GUIDANCE_EPS_CONSENSUS", "BEAT_RATE",
"TREND_OPERATING_MARGIN", "TREND_EPS"},
"VALUATION": {"REL_PRICE_INTRINSIC", "REL_PRICE_MECHANICAL", "REL_PRICE_MARKET_COMPOSITE", "REL_TARGET_PRICE",
"PE_FORWARD", "PE_TRAILING", "PEG", "LIFECYCLE_STAGE", "DMD_BETA", "DMD_UNLEVERED_BETA", "DMD_ERP",
"DMD_IMPLIED_ERP", "DMD_CRP", "DMD_S2C", "REL_EXPECTATIONS_STORY", *_STORY_ASSUMPTIONS, *_BREAK_EVEN},
"EXPECTATIONS": {"REL_EXPECTATIONS_STORY", *_BREAK_EVEN},
"RISK": {"STORY_FAILURE", "MOMENTUM", "SMA200", "REL_EXPECTATIONS_STORY", *_BREAK_EVEN, *_TRENDS},
"COMPETITION": {"REL_OPERATING_MARGIN_INDUSTRY"},
"MANAGEMENT": set(),
"OWNERSHIP": set(), # filled from the 13F / insider families only when DIRECTIONAL_ALLOWED
}
# guidance rows by concept (the row's typed concept, never its label)
GUIDANCE_KIND = {"REVENUE": ("OUTLOOK", "RISK"), "REVENUE_GROWTH": ("OUTLOOK", "RISK"), "ORGANIC_REVENUE_GROWTH": ("OUTLOOK", "RISK"),
"EPS": ("EARNINGS", "RISK"), "EPS_GROWTH": ("EARNINGS", "RISK"), "OPERATING_MARGIN": ("EARNINGS", "RISK"),
"OPERATING_MARGIN_CHANGE": ("EARNINGS", "RISK"), "GROSS_MARGIN": ("EARNINGS",),
"GROSS_MARGIN_CHANGE": ("EARNINGS",), "NET_MARGIN": ("EARNINGS",), "OPERATING_INCOME": ("EARNINGS", "RISK"),
"OPERATING_EXPENSES": ("EARNINGS",), "SHARE_REPURCHASE": ("MANAGEMENT",), "DIVIDEND": ("MANAGEMENT",),
"CAPEX": ("MANAGEMENT",), "FREE_CASH_FLOW": ("EARNINGS", "MANAGEMENT")}
# never offered: pipeline internals, reference values, dates, over-read evidence (measured: cited wrongly more often than
# rightly), standalone industry aggregates (the typed benchmark relation carries the comparison) and model machinery.
# Past share-price moves (RETURN_*_PHRASE): every use in the measured runs read a past move as the cause of a future one
# (4 of 4 in run 8d12481bffa6e2e6); the report prints them itself, and no typed technical permission makes them directional.
WITHHELD = frozenset({"WACC_BENCHMARK", "MARKET_COMPOSITE_RAW", "MARKET_COMPOSITE_POST_TRIM", "ANALYST_TARGET_ADJUSTED",
"DMD_ERP_MONTH", "DMD_COUNTRY", "OWN_13F_AS_OF", "SEGMENTS_COVERAGE", "ADJ_GAAP_BRIDGE", "CEO",
"CFO", "SEGMENT_COUNT_FILED", "SEGMENT_COUNT_CONTINUING", "DMD_MARGIN", "DMD_INDUSTRY", "DMD_DE",
"RETURN_12M_PHRASE", "RETURN_3M_PHRASE"})
def kinds_for(cid, cat):
"""The kinds a claim may support (empty = not author evidence)."""
base = str(cid).split("__")[0]
out = {k for k, ids in KIND_IDS.items() if base in ids}
cl = cat.get(base) if cat is not None else None
if getattr(cl, "stype", None) == C.GUIDANCE:
out |= set(GUIDANCE_KIND.get(((cl.value or {}) if isinstance(cl.value, dict) else {}).get("concept"), ()))
return out
# ── context ────────────────────────────────────────────────────────────────────────────────────────────────
@dataclass
class Context:
state: object
cat: dict
rules: object # prose.Rules (typed rule inputs: relations, assessments, turns …)
offer: frozenset
kind_evidence: dict # kind -> frozenset of ids
relations: dict # REL id -> {"lhs", "rhs", "magnitude_claim", "type"}
internal_terms: tuple = ()
qa_vocab: object = None
kinds: tuple = ()
cases: dict = field(default_factory=dict) # REL_EXPECTATIONS_STORY alternative cases -> claim ids
def author_offer(state, cat):
"""The claim ids an author may cite: offered ∩ KIND_EVIDENCE − WITHHELD (+ ownership only when directional)."""
import report_model as RM
interp = ((state.get("evidence.state") or {}).get("interpretation") or {})
table = {f"GUIDANCE_{i}" for i, _m in RM.guidance_table_rows(state, cat)}
trailing_forward_ok = True
try:
cmp_ = C.comparison(state, "LIFECYCLE_TRAILING_GROWTH", "LIFECYCLE_FORWARD_GROWTH")
trailing_forward_ok = cmp_ is None or bool(cmp_[0])
except Exception: # noqa: BLE001
trailing_forward_ok = False
directional = {fam for fam, key in (("OWN_13F_", "13F"), ("INSIDER_", "insider"))
if (interp.get(key) or {}).get("permission") == EV.DIRECTIONAL_ALLOWED}
out = set()
for cid in C.offered(state, cat, include_deferred=False):
if "__" in cid or cid in WITHHELD:
continue # authors cite a relation, never one of its render forms
cl = cat.get(cid)
if cl is None or cl.rendered is None:
continue
if any(cid.startswith(pre) for pre in directional):
out.add(cid)
continue
if not kinds_for(cid, cat):
continue
if getattr(cl, "stype", None) == C.GUIDANCE and cid not in table:
continue
if cid == "LIFECYCLE_TRAILING_GROWTH" and not trailing_forward_ok:
continue # its only use would be a cross-perimeter comparison
out.add(cid)
return frozenset(out)
def context(state, cat, rules, internal_terms=(), qa_vocab=None):
offer = author_offer(state, cat)
kev = {}
for k in KINDS:
if k == "OWNERSHIP":
ids = {c for c in offer if c.startswith(("OWN_13F_", "INSIDER_"))}
else:
ids = {c for c in offer if k in kinds_for(c, cat)}
if ids:
kev[k] = frozenset(ids)
items = ((state.get("relations.state") or {}).get("items")) or {}
rels = {rid: {"lhs": r.get("lhs_claim_id"), "rhs": r.get("rhs_claim_id"), "magnitude_claim": r.get("presentation_claim_id"),
"years": r.get("years_claim_id"), "type": r.get("relation_type")} for rid, r in items.items()}
ex = items.get("REL_EXPECTATIONS_STORY") or {}
cases = dict(ex.get("alternative_case_claims") or {}) if ex.get("logical_structure") == "ALTERNATIVE_CONDITIONALS" else {}
return Context(state, cat, rules, offer, kev, rels, tuple(internal_terms or ()), qa_vocab, tuple(kev), cases)
def constituents(ctx, rid):
"""The claims a relation states (its sides, magnitude and years); for the expectations frontier, its cases'."""
if rid == "REL_EXPECTATIONS_STORY":
out = set()
for ids in ctx.cases.values():
for c in ids:
out |= constituents(ctx, c) if c in ctx.relations else {c}
return out
r = ctx.relations.get(rid) or {}
return {x for x in (r.get("lhs"), r.get("rhs"), r.get("magnitude_claim"), r.get("years")) if x}
# ── parsing: tolerant, meaning-preserving normalizations only ───────────────────────────────────────────────
_KIND_ALIASES = {"OUTLOOK": "OUTLOOK", "BUSINESS OUTLOOK": "OUTLOOK", "GROWTH": "OUTLOOK", "DEMAND": "OUTLOOK",
"EARNINGS": "EARNINGS", "MARGINS": "EARNINGS", "PROFITABILITY": "EARNINGS",
"COMPETITION": "COMPETITION", "COMPETITIVE LANDSCAPE": "COMPETITION", "MOAT": "COMPETITION",
"COMPETITIVE POSITION": "COMPETITION", "RISK": "RISK", "RISKS": "RISK", "BUSINESS RISKS": "RISK",
"MANAGEMENT": "MANAGEMENT", "CAPITAL ALLOCATION": "MANAGEMENT", "VALUATION": "VALUATION",
"MULTIPLES": "VALUATION", "INTRINSIC VALUE": "VALUATION", "EXPECTATIONS": "EXPECTATIONS",
"PRICED IN": "EXPECTATIONS", "WHAT THE PRICE REQUIRES": "EXPECTATIONS", "OWNERSHIP": "OWNERSHIP"}
_VERDICT_ALIASES = {"FAILS": "FAILS", "FAIL": "FAILS", "REFUTED": "FAILS", "WRONG": "FAILS", "FALSE": "FAILS",
"DISPUTED": "FAILS", "PARTLY": "PARTLY", "PARTIAL": "PARTLY", "MIXED": "PARTLY",
"PARTLY HOLDS": "PARTLY", "HOLDS": "HOLDS", "VALID": "HOLDS", "STANDS": "HOLDS",
"CONCEDED": "HOLDS", "AGREE": "HOLDS", "CONCEDE": "HOLDS"}
_ID = re.compile(r"[A-Za-z][A-Za-z0-9_]*")
def coerce_ids(x):
"""A list of base claim ids from a list or "A, B" or "[[A]]" or "A (label)"; never fuzzy-matched."""
items = x if isinstance(x, list) else ([x] if isinstance(x, str) else [])
out = []
for it in items:
if not isinstance(it, str):
continue
for part in re.split(r"[,;\s]+(?=\[?\[?[A-Za-z])", it.strip()):
m = _ID.search(part.replace("[", " ").replace("]", " ").replace("{", " ").replace("}", " "))
if not m:
continue
cid = m.group(0).upper().split("__")[0]
cid = C.PHRASE_OF.get(cid, cid) # a signed return is cited through its phrase claim
if cid not in out:
out.append(cid)
return out
def _kind(x):
return _KIND_ALIASES.get(re.sub(r"\s+", " ", str(x or "")).strip().upper())
def _text(x):
if not isinstance(x, str):
return None
t = re.sub(r"\s+", " ", x).strip().strip('"').strip()
t = re.sub(r"^[-•*]\s+", "", t)
return t or None
def _view_of(d):
for k in ("view", "point", "text", "judgement", "judgment", "argument"):
if isinstance(d.get(k), str):
return _text(d[k])
return None
def _evidence_of(d):
for k in ("evidence", "evidence_ids", "claim_ids", "claims", "ids", "tokens", "citations"):
if k in d:
return coerce_ids(d[k])
return []
def is_structured(raw):
"""True when a persona reply carries structured-argument objects."""
st = (raw or {}).get("structured") or {}
pts = st.get("points") or st.get("factors") or st.get("answers")
return isinstance(pts, list) and any(isinstance(p, dict) and _view_of(p) for p in pts)
# ── text rules T1–T11 (typed values or closed grammatical classes only; no lexicon) ─────────────────────────────
_RAW_ID = P.IDENTIFIER # one identifier class for authoring, ingestion and the report audit
_QUOTES = re.compile(r"[\"“”]|(?<![A-Za-z])['‘][^'’]{12,}['’](?![A-Za-z])")
_BAND_WORDS = tuple(b for b, _lo, _hi in P.MAGNITUDE_BANDS)
_SPELLED_CARDINAL = re.compile(r"\b(two|three|four|five|six|seven|eight|nine|ten|eleven|twelve|thirteen|fourteen|fifteen|"
r"sixteen|seventeen|eighteen|nineteen|twenty|thirty|forty|fifty|sixty|seventy|eighty|ninety|"
r"hundred|thousand|million|billion|trillion)\b", re.I)
def text_reasons(ctx, text, *, max_words, form=True):
"""T1–T5 on an authored text: shape, numerals, rating labels, internal vocabulary, form."""
r = []
if not text:
return ["EMPTY"]
if "[[" in text or "]]" in text or "{" in text or "}" in text:
r.append("TOKEN_IN_VIEW: claim ids go in evidence, never in the text")
m = _RAW_ID.search(text)
if m or any(re.search(r"\b" + re.escape(c) + r"\b", text) for c in ctx.offer):
r.append(f"ID_TEXT_IN_VIEW: '{m.group(0) if m else 'claim id'}'")
if _QUOTES.search(text):
r.append("QUOTE_IN_VIEW: quotations are not published in authored text")
if re.search(r"\d", text):
r.append("NUMERAL: the report prints every figure from the evidence")
else:
low = P._dash(text.lower())
wm = P.MATERIAL_NUMBER.search(P.BENIGN.sub(" ", text))
band = next((b for b in _BAND_WORDS if b in low or b.replace("-", " ") in low), None)
sc = _SPELLED_CARDINAL.search(re.sub(r"\b[a-z]+[-\u2010\u2011 ](?:month|year|quarter|day|week)s?\b", " ", low))
if wm or band or sc:
r.append(f"NUMERAL: '{(wm.group(0) if wm else band or sc.group(0)).strip()}'")
if P.RATING_WORD.search(text):
r.append("RATING_WORD: the report prints each rating itself")
pl = P.public_language_violation(text, ctx.internal_terms)
if pl or (ctx.qa_vocab is not None and ctx.qa_vocab.search(text)):
r.append(f"INTERNAL_VOCAB: {pl or 'QA vocabulary'}")
low = text.lower()
for t in getattr(ctx.rules, "engine_forbidden", ()) or ():
if re.search(r"\b" + re.escape(t.lower()) + r"\b", low) and not P.DENIAL.search(low):
r.append(f"INTERNAL_VOCAB: '{t}' is not this issuer's valuation vocabulary")
break
for topic, phrases in ((P.PRICE_MODEL_TOPIC, getattr(ctx.rules, "quant_forbidden_phrases", ()) or ()),
(P.PANEL_TOPIC, getattr(ctx.rules, "panel_forbidden_phrases", ()) or ())):
if topic.search(text) and any(re.search(r"\b" + re.escape(ph.lower()) + r"\b", low) for ph in phrases):
r.append("INTERNAL_VOCAB: describes a quantitative model more strongly than its reliability allows")
break
n = len([w for w in re.split(r"\s+", text.strip()) if w])
if n > max_words:
r.append(f"WORD_LIMIT: {n} words (at most {max_words})")
if re.search(r"(?:…|\.\.\.)\W*$", text.strip()) or not re.search(r"[.!?][\"'”’)\]]*$", text.strip()):
r.append("INCOMPLETE: not a complete sentence")
if form and P.ANAPHORIC_OPENING.search(text):
r.append("ANAPHORIC_OPENING: a point stands on its own")
return r
# ── claim lineage: engine / company-guidance figures against consensus (typed provenance, never a word list) ──────
# the source class of a claim, by its typed family; the subject it measures, by its economic concept
_CONSENSUS_IDS = frozenset({"CONSENSUS_EPS_FY1", "CONSENSUS_EPS_FY2", "CONSENSUS_EPS_NTM", "CONSENSUS_REVENUE_FY1",
"CONSENSUS_REVENUE_FY2", "CONSENSUS_REVENUE_NTM", "LIFECYCLE_FORWARD_GROWTH"})
_SUBJECT_OF_CONCEPT = {"REVENUE": "revenue", "REVENUE_GROWTH": "revenue", "ORGANIC_REVENUE_GROWTH": "revenue",
"EPS": "eps", "EPS_GROWTH": "eps"}
# the ONE relation that may compare each (source, subject) with consensus
_CONNECT = {("ENGINE", "revenue"): "REL_ENGINE_REVENUE_CONSENSUS", ("ENGINE", "eps"): "REL_FORECAST_EPS_CONSENSUS",
("GUIDANCE", "eps"): "REL_GUIDANCE_EPS_CONSENSUS", ("GUIDANCE", "revenue"): None}
# how a reader names each source class: the catalog's own labels (a consensus claim may never be labelled with the
# engine's or the company's words, and vice versa: catalog._CONSENSUS_WORDS / _FORECAST_WORDS) and prose.GUIDANCE_WORDS
_CLASS_NAMES = {"CONSENSUS": tuple(w for w in C._FORECAST_WORDS if "consensus" in w) + ("street",),
"ENGINE": tuple(w for w in C._CONSENSUS_WORDS if w != "guidance") + ("engine's forecast", "our estimate")}
def provenance(cid, cat):
"""ENGINE / GUIDANCE / CONSENSUS for a claim, or None."""
if getattr(cat.get(cid), "stype", None) == C.GUIDANCE:
return "GUIDANCE"
if cid in _CONSENSUS_IDS:
return "CONSENSUS"
if str(cid).startswith("FORECAST_"):
return "ENGINE"
return None
def _subject(ctx, cid):
try:
m = C._claim_metric(ctx.state, cid)
except Exception: # noqa: BLE001
return None
return _SUBJECT_OF_CONCEPT.get(getattr(m, "economic_concept", None)) if m is not None else None
def _names_class(low, cls):
if cls == "GUIDANCE":
return bool(P.GUIDANCE_WORDS.search(low))
return any(re.search(r"\b" + re.escape(n) + r"\b", low) for n in _CLASS_NAMES.get(cls, ()))
def lineage_reasons(ctx, text, scope, cocite=True, named=True):
"""An engine or company-guidance figure compared with consensus only through the typed relation that establishes
the comparison (same horizon, perimeter, basis; an engine figure independent of consensus). Two checks, both typed,
neither reading the predicate (aligned, below, close to ... are never enumerated):
CO-CITATION — the text's evidence holds an engine/guidance claim and a consensus claim of the same subject;
NAMED COUNTERPART — the text cites one side and names the other source class by the catalog's own class labels
(catalog._FORECAST_WORDS / _CONSENSUS_WORDS, prose.GUIDANCE_WORDS) without citing it: two sources of one subject in
one statement are a comparison. Either way the connecting relation must be cited; where none exists the figures may
be stated in separate statements.
``cocite`` is off for a thesis, whose scope is the union of its points' evidence rather than its own citation;
``named`` is off for template sentences (typed renderings, audited by co-citation only)."""
ids = list(dict.fromkeys(scope or []))
rels = [e for e in ids if e in ctx.relations]
own = {}
for e in ids:
members = covered(ctx, e) if e in ctx.relations else {e}
for x in members:
own.setdefault(x, e) # the cited id a claim arrived through (itself, or its relation)
typed = {x: (provenance(x, ctx.cat), _subject(ctx, x)) for x in own}
def need(side, subj, a, b=None):
rel = _CONNECT.get((side, subj))
# the cited relation must state THIS pair (a guidance relation on the GAAP row does not cover the adjusted row)
if rel and rel in rels and {a, b} - {None} <= covered(ctx, rel):
return None
code = "GUIDANCE_CONSENSUS_COMPATIBILITY" if side == "GUIDANCE" else "FORECAST_RELATION_REQUIRED"
what = f"{a} and {b}" if b else f"{a} and consensus"
if rel and rel in ctx.relations:
return f"{code}: {what} are compared only through {rel}"
need_ = ("the same fiscal period and horizon, a compatible accounting and security basis" if side == "GUIDANCE"
else "the same horizon, perimeter and basis, and an engine figure independent of consensus")
return f"{code}: no typed relation compares {what} (it needs {need_}); state them separately"
for a, (pa, sa) in (typed.items() if cocite else ()):
for b, (pb, sb) in typed.items():
if pa in ("ENGINE", "GUIDANCE") and pb == "CONSENSUS" and sa and sa == sb:
r = need(pa, sa, a, b) # None when the connecting relation itself is cited
if r:
return [r]
low = P._dash(str(text or "").lower())
if named:
present = {(p, s) for p, s in typed.values() if p and s}
for side, subj in present:
if side in ("ENGINE", "GUIDANCE") and ("CONSENSUS", subj) not in present and _names_class(low, "CONSENSUS"):
r = need(side, subj, next(x for x, v in typed.items() if v == (side, subj)))
if r:
return [r]
if side == "CONSENSUS":
for other in ("GUIDANCE", "ENGINE"):
if (other, subj) not in present and _names_class(low, other):
r = need(other, subj, next(x for x, v in typed.items() if v == (side, subj)))
if r:
return [r]
return []
def _probe(text, scope):
return text + " ; " + " ".join(f"[[{e}]]" for e in scope)
def direction_reasons(ctx, text, scope, own, *, cocite=True):
"""T6–T11: the text against its evidence SCOPE and the author's own return (typed values only)."""
R = ctx.rules
r = []
vrels = R.valuation_relations or {}
probe = _probe(text, scope)
vr = P.valuation_relation_violation(probe, vrels) or P.universal_valuation_violation(text, vrels)
if vr:
r.append(f"VALUATION_CONTRADICTS: {vr}")
in_scope = [e for e in scope if e in vrels]
words = [w for t in vrels.values() for w in _VALUATION_WORDS.get(t, ())]
low = text.lower()
hit = next((w for w in _DIR_OF_WORD if re.search(r"\b" + w + r"\b", low)), None)
if hit and not in_scope and not P.DENIAL.search(low):
r.append(f"VALUATION_UNCITED: '{hit}' needs a cited valuation relation")
elif hit and in_scope and not P.DENIAL.search(low):
want = _DIR_OF_WORD.get(hit)
if want and all(vrels[e] != want for e in in_scope):
r.append(f"VALUATION_CONTRADICTS: '{hit}' but every cited anchor holds the other direction")
av = P.assessment_violation(probe, R.assessments, R.assumption_claims)
if av:
r.append(f"ASSESSMENT_CONTRADICTS: {av}")
if own.get("value") is not None:
rd = P.return_direction_violation(text, own["value"])
if rd:
r.append(f"RETURN_SIGN: {rd}")
# speech attributed to management needs its own quoted words, and quotations are not published in authored text
# (T1): so no statement may say what management said, whether or not a transcript exists
sp = re.search(P._SPEECH, low)
if sp and (re.search(_SPEAKERS, low[:sp.start()]) or any(w and w in low[:sp.start()] for w in R.company_words)):
r.append("ATTRIBUTION: a statement about what management said is not publishable without its quoted words")
for m in P.ROLE_NAME.finditer(text):
role = {"CHIEF EXECUTIVE OFFICER": "CEO", "CHIEF FINANCIAL OFFICER": "CFO",
"CHIEF OPERATING OFFICER": "COO"}.get(m.group(1).upper(), m.group(1).upper())
canon = (R.officers or {}).get(role)
if not canon or not P._same_person(m.group(2).strip(), canon):
r.append(f"OFFICER: names {m.group(2).strip()} as {role}")
break
if P.GUIDANCE_WORDS.search(text) and not any(str(e).startswith("GUIDANCE_") for e in scope) and not P.DENIAL.search(low):
r.append("GUIDANCE_UNCITED: a statement about guidance needs a cited guidance row")
lc = P.lifecycle_violation(text, ctx.cat, R.company_words)
if lc:
r.append(f"LIFECYCLE: {lc}")
r += lineage_reasons(ctx, text, scope, cocite=cocite)
return r
# who can be said to have said something (the company's people; "guidance" is a document, not a speaker)
_SPEAKERS = r"\b(?:management|the company|company|executives?|ceo|cfo|coo|chief executive|chief financial|leadership)\b"
# evaluative valuation adjectives describe the stock outright; "premium"/"discount" are valuation words only next to an
# anchor, which valuation_relation_violation already binds (a "premium product" is not a valuation claim)
_DIR_OF_WORD = {"cheap": "BELOW", "undervalued": "BELOW", "expensive": "ABOVE", "overvalued": "ABOVE"}
_VALUATION_WORDS = {"ABOVE": ("expensive", "overvalued", "premium"), "BELOW": ("cheap", "undervalued", "discount")}
def covered(ctx, rid):
"""What a cited relation already states: its constituents and, for the expectations frontier, its case relations."""
out = set(constituents(ctx, rid))
if rid == "REL_EXPECTATIONS_STORY":
out |= {c for ids in ctx.cases.values() for c in ids}
return out
def evidence_reasons(ctx, ids, kind, *, allowed=None, required=True, max_ids=None):
"""Evidence rules (typed): known, offered, allowed, pairs, single case. Any offered id may support any kind: the kind
labels the point, and the pair and relation rules protect what the evidence can show (the per-kind lists dropped
valid evidence: a buyback guidance row under MANAGEMENT, an answer outside its target's kind). An id that a cited
relation already states adds no evidence, so it does not count toward the limit."""
r = []
cited = [e for e in ids if e in ctx.relations]
cov = set().union(*[covered(ctx, x) for x in cited]) if cited else set()
n = len([e for e in ids if e not in cov])
if n > (max_ids or LIMITS["evidence"]):
r.append(f"EVIDENCE_COUNT: at most {max_ids or LIMITS['evidence']} ids")
for e in ids:
if ctx.cat.get(e) is None:
r.append(f"UNKNOWN_ID: {e}")
elif e not in ctx.offer:
r.append(f"WITHHELD_EVIDENCE: {e}")
elif allowed is not None and e not in allowed:
r.append(f"ID_NOT_ALLOWED: {e} cannot support this point")
if required and not ids:
r.append("NO_EVIDENCE: every point cites at least one id")
if r:
return r
rels_c = [e for e in ids if e in ctx.relations]
facts = [e for e in ids if e not in ctx.relations]
K = set().union(*[constituents(ctx, x) for x in rels_c]) if rels_c else set()
pair_fn = getattr(ctx.rules, "pair_fn", None)
if pair_fn is not None:
for i, a in enumerate(facts):
for b in facts[i + 1:] + sorted(K - {a}):
if a == b or (b in K and a in K):
continue
try:
st = pair_fn(a, b)
except Exception: # noqa: BLE001
st = None
if st is None:
continue
if st[0] == "RELATION":
if st[1] in rels_c:
continue
return [f"PAIR_RELATION_REQUIRED: {a} and {b} are compared only through {st[1]}"]
if st[0] == "OK":
return [f"PAIR_UNRELATED: {a} and {b} have no typed relation (cite the relation, or put them in "
"separate points)"]
if benign_cocitation(ctx, a, b):
continue
return [f"PAIR_INCOMPATIBLE: {a} and {b} ({'; '.join(st[1])[:90]})"]
for i, x in enumerate(rels_c): # across two cited relations only an incompatible pair drops
for y in rels_c[i + 1:]:
for a in constituents(ctx, x):
for b in constituents(ctx, y):
if a == b:
continue
try:
st = pair_fn(a, b)
except Exception: # noqa: BLE001
st = None
if st and st[0] == "INCOMPATIBLE":
return [f"PAIR_INCOMPATIBLE: {x} and {y} ({'; '.join(st[1])[:90]})"]
# a property of the evidence set, whatever the kind (a RISK point, a PM factor or a summary item citing both solves
# framed them as one joint requirement in run 8d12481bffa6e2e6)
if ctx.cases and "REL_EXPECTATIONS_STORY" not in ids:
hit = [c for c, cids in ctx.cases.items() if set(ids) & set(cids)]
if len(hit) > 1:
return ["EXPECTATIONS_JOINT_CASES: the revenue and margin solves are alternatives; cite REL_EXPECTATIONS_STORY"]
return r
def benign_cocitation(ctx, a, b):
"""Two facts that may not be COMPARED can still share a point when citing them together cannot read as a
like-for-like comparison of one quantity: different concepts on the same perimeter, basis and quantity type
(operating and net margin; reported and organic growth), or the same concept for consecutive fiscal years (a
trajectory, FY1 then FY2). A perimeter, accounting-basis or level/change mismatch, or partly overlapping periods,
still drop: those are the measured defects (reviewers judged the rest of the pair drops good content)."""
import datetime as _dt
try:
ma, mb = C._claim_metric(ctx.state, a), C._claim_metric(ctx.state, b)
except Exception: # noqa: BLE001
return False
if ma is None or mb is None or ma.perimeter != mb.perimeter:
return False
na = ("NA", None, "")
if ma.accounting_basis not in na and mb.accounting_basis not in na and ma.accounting_basis != mb.accounting_basis:
return False
if ma.quantity_type and mb.quantity_type and ma.quantity_type != mb.quantity_type:
return False
if ma.economic_concept != mb.economic_concept:
return True
try:
pairs = [(_dt.date.fromisoformat(str(x.period_start)[:10]), _dt.date.fromisoformat(str(x.period_end)[:10]))
for x in (ma, mb)]
except (TypeError, ValueError):
return False
(s1, e1), (s2, e2) = sorted(pairs)
return 0 <= (s2 - e1).days <= 5
def cross_reasons(ctx, ids, target_ids):
"""An answer's evidence against the evidence of the point it answers: only an INCOMPATIBLE pair drops (different
period, perimeter or basis); unrelated quantities may answer each other."""
pair_fn = getattr(ctx.rules, "pair_fn", None)
if pair_fn is None:
return []
def expand(xs):
out = set()
for x in xs:
out |= covered(ctx, x) if x in ctx.relations else {x}
return out
for a in sorted(expand(ids)):
for b in sorted(expand(target_ids)):
if a == b:
continue
try:
st = pair_fn(a, b)
except Exception: # noqa: BLE001
st = None
if st and st[0] == "INCOMPATIBLE" and not benign_cocitation(ctx, a, b):
return [f"PAIR_INCOMPATIBLE_WITH_TARGET: {a} and {b} ({'; '.join(st[1])[:90]})"]
return []
def _labels(x):
"""[(prefix, number)] from [1, 3] / ["P1", "A2"] / "1, 3": each entry a bare number or ONE letter + number."""
items = x if isinstance(x, list) else ([x] if isinstance(x, (str, int)) and not isinstance(x, bool) else [])
out = []
for it in items:
if isinstance(it, bool):
continue
if isinstance(it, int):
out.append(("", it))
continue
for part in re.split(r"[,;]\s*", str(it)):
m = re.fullmatch(r"\s*([A-Za-z]?)\s*(\d+)\s*", part)
if m:
out.append((m.group(1).upper(), int(m.group(2))))
return out
def _positions(x):
return [k for _pre, k in _labels(x)]
def rests_on(raw, objs, own="P", by_target=False):
"""The validated numbers (n) of the objects a thesis rests on. A bare number or the author's own prefix names its
own object by position (P for a seat's points, A for a rebuttal's answers, F for the PM's factors); in a rebuttal an
opponent P-label names the answer addressed to that point. Any other label names nothing (never a digit match)."""
by_src = {o.get("src"): o["n"] for o in objs or []}
by_to = {o.get("to"): o["n"] for o in objs or []} if by_target else {}
out = []
for pre, k in _labels(raw):
if pre in ("", own):
n = by_src.get(k)
elif by_target and pre == "P":
n = by_to.get(f"P{k}")
else:
n = None
if n is not None and n not in out:
out.append(n)
return out
def _norm(t):
return re.sub(r"[^a-z0-9 ]", "", re.sub(r"\s+", " ", str(t or "").lower())).strip()
def _contains(big, small):
n = len(small)
return any(big[i:i + n] == small for i in range(len(big) - n + 1)) if n else False
def same_text(reply, opening):
"""Typed identity on whole tokens: the reply IS the opening, a contiguous fragment of it, or the opening with at
most a fifth of new words. A longer reply that merely contains a short opening says something new."""
x, y = _norm(reply).split(), _norm(opening).split()
if not x or not y:
return False
return x == y or _contains(y, x) or (_contains(x, y) and len(y) >= 0.8 * len(x))
def _own(value, label, return_claim=None):
return {"value": value, "label": label, "return_claim": return_claim}
def validate_points(ctx, raw_points, *, own, limit, source, allowed=None):
"""[validated point dicts], [drops]. Validated points are numbered P1… in order (frozen when forwarded)."""
out, drops, seen = [], [], set()
for i, p in enumerate(raw_points if isinstance(raw_points, list) else []):
if len(out) >= limit:
drops.append({"source": f"{source}.{i}", "reason": "OVERFLOW", "raw": p})
continue
if not isinstance(p, dict):
drops.append({"source": f"{source}.{i}", "reason": "SCHEMA: not an object", "raw": p})
continue
kind, view, ev = _kind(p.get("kind") or p.get("topic")), _view_of(p), _evidence_of(p)
rs = []
if kind is None or kind not in ctx.kinds: # a kind with no permitted evidence (e.g. OWNERSHIP when 13F and
rs.append(f"KIND_UNKNOWN: {p.get('kind') or p.get('topic')!r}") # insiders are descriptive-only) is not open
else:
rs += evidence_reasons(ctx, ev, kind, allowed=None if allowed is None else set(allowed) & set(ctx.offer))
rs += text_reasons(ctx, view, max_words=LIMITS["view"])
if not rs:
rs += direction_reasons(ctx, view, ev, own)
key = (kind, tuple(sorted(ev)))
if not rs and key in seen:
rs.append("DUPLICATE: same kind and evidence as an earlier point")
if not rs and any(_norm(view) == _norm(o["view"]) for o in out):
rs.append("DUPLICATE: the same words as an earlier point")
if rs:
drops.append({"source": f"{source}.{i}", "reason": "; ".join(rs)[:400], "raw": p})
continue
seen.add(key)
out.append({"n": len(out) + 1, "src": i + 1, "kind": kind, "evidence": ev, "view": view})
return out, drops
def validate_thesis(ctx, text, *, own, max_words, source, scope=(), rests=None, named=True, repeat_of=None):
"""A thesis is judged against the evidence of the points it RESTS ON (``rests``: their validated numbers). A thesis
whose points were all removed has nothing under it (run 8d12481bffa6e2e6 published "earnings quality is overstated
by adjustments and buyback dependency" after both supporting points were dropped)."""
t = _text(text)
if not t:
return None, []
rs = text_reasons(ctx, t, max_words=max_words, form=False)
if rests is not None and not rests:
rs.append("THESIS_ORPHANED: " + ("name the points it rests on in thesis_rests_on" if not named
else "every point it rests on was removed"))
if repeat_of and same_text(t, repeat_of):
rs.append("REPEATS_OPENING: the reply restates the opening thesis; say what the exchange changed")
if not rs:
rs = direction_reasons(ctx, t, list(scope), own, cocite=False)
if rs:
return None, [{"source": source, "reason": "; ".join(rs)[:400], "raw": t}]
return t, []
def _scope(points):
return [e for p in points or [] for e in p.get("evidence") or []]
def validate_seat(ctx, role, reply, *, own):
"""A round-1 seat (neutral / bear / bull) → the argument set and the drops."""
st = (reply or {}).get("structured") or {}
pts, d2 = validate_points(ctx, st.get("points"), own=own, limit=LIMITS["points"], source=f"{role}.points")
rest = rests_on(st.get("thesis_rests_on"), pts, own="P")
thesis, d1 = validate_thesis(ctx, st.get("thesis"), own=own, max_words=LIMITS["thesis"], source=f"{role}.thesis",
scope=_scope([p for p in pts if p["n"] in rest]), rests=rest,
named=bool(_labels(st.get("thesis_rests_on"))))
return {"schema": SCHEMA, "role": role, "format": "v2", "thesis": thesis, "thesis_rests_on": rest if thesis else [],
"points": pts}, d1 + d2
def validate_rebuttal(ctx, role, reply, *, own, opponent, opponent_role, r1_value, r1_label, opening_thesis=None):
"""A rebuttal: answers point at the opponent's VALIDATED points (P-labels); the stance is derived from the numbers."""
st = (reply or {}).get("structured") or {}
targets = {f"P{p['n']}": p for p in (opponent or {}).get("points") or []}
stance = stance_of(r1_value, r1_label, own.get("value"), own.get("label"))
if not targets: # nothing validated to answer: not the author's to fix, so never a repair request
drops = [{"source": f"{role}.rebuttal", "reason": "NO_TARGETS: the opponent has no validated points", "raw": st}] \
if (st.get("answers") or st.get("thesis")) else []
return {"schema": SCHEMA, "role": role, "format": "v2", "thesis": None, "thesis_rests_on": [], "answers": [],
"stance": stance}, drops
answers, used, drops = [], set(), []
for i, a in enumerate(st.get("answers") if isinstance(st.get("answers"), list) else []):
src = f"{role}.rebuttal.answers.{i}"
if len(answers) >= LIMITS["answers"]:
drops.append({"source": src, "reason": "OVERFLOW", "raw": a})
continue
if not isinstance(a, dict):
drops.append({"source": src, "reason": "SCHEMA: not an object", "raw": a})
continue
m = re.search(r"(\d+)", str(a.get("to") or ""))
lab = f"P{int(m.group(1))}" if m else None
tgt = targets.get(lab)
verdict = _VERDICT_ALIASES.get(str(a.get("verdict") or "").strip().upper())
ev, view = _evidence_of(a), _view_of(a)
rs = []
if tgt is None:
rs.append(f"BAD_TARGET: {a.get('to')!r} is not one of the opponent's points")
elif lab in used:
rs.append("BAD_TARGET: the point was already answered")
if verdict is None:
rs.append(f"VERDICT_UNKNOWN: {a.get('verdict')!r}")
if tgt is not None:
# the author's whole offer, checked for incompatibility against the evidence of the point it answers
rs += evidence_reasons(ctx, ev, None, required=(verdict == "FAILS"), max_ids=LIMITS["answer_evidence"]) or \
cross_reasons(ctx, ev, tgt.get("evidence") or [])
rs += text_reasons(ctx, view, max_words=LIMITS["view"])
if not rs:
rs += direction_reasons(ctx, view, ev + list((tgt or {}).get("evidence") or []), own)
if rs:
drops.append({"source": src, "reason": "; ".join(rs)[:400], "raw": a})
continue
used.add(lab)
answers.append({"n": len(answers) + 1, "src": i + 1, "to": lab, "to_role": opponent_role, "to_kind": tgt["kind"],
"to_view": tgt["view"], "to_evidence": list(tgt.get("evidence") or []), "verdict": verdict,
"evidence": ev, "view": view})
rest = rests_on(st.get("thesis_rests_on"), answers, own="A", by_target=True)
thesis, d1 = validate_thesis(ctx, st.get("thesis"), own=own, max_words=LIMITS["rebuttal_thesis"],
source=f"{role}.rebuttal.thesis", scope=_scope([x for x in answers if x["n"] in rest]),
rests=rest, named=bool(_labels(st.get("thesis_rests_on"))), repeat_of=opening_thesis)
return {"schema": SCHEMA, "role": role, "format": "v2", "thesis": thesis, "thesis_rests_on": rest if thesis else [],
"answers": answers, "stance": stance}, d1 + drops
def stance_of(r1_value, r1_label, value, label):
"""HOLD / REVISED_RETURN / REVISED_LABEL from the numbers (the typed stance, never authored)."""
if r1_label and label and r1_label != label:
return "REVISED_LABEL"
if r1_value is not None and value is not None and abs(float(value) - float(r1_value)) >= 0.005:
return "REVISED_RETURN"
return "HOLD"
def debate_evidence(sets):
"""The ids the validated seats cited (the PM and the summary may not introduce new evidence)."""
return {e for a in (sets or {}).values() if a for key in ("points", "answers") for p in a.get(key) or []
for e in p.get("evidence") or []}
def validate_pm(ctx, reply, *, own, debate_ids=None):
st = (reply or {}).get("structured") or {}
if debate_ids is not None and not debate_ids: # the debate published no evidence: nothing may be cited
return {"schema": SCHEMA, "role": "pm", "format": "v2", "thesis": None, "thesis_rests_on": [], "points": []}, \
([{"source": "pm", "reason": "NO_ALLOWED_EVIDENCE: the validated debate cited no evidence", "raw": st}] if st else [])
pts, d2 = validate_points(ctx, st.get("factors") or st.get("points"), own=own, limit=LIMITS["factors"],
source="pm.factors", allowed=debate_ids)
rest = rests_on(st.get("thesis_rests_on"), pts, own="F")
thesis, d1 = validate_thesis(ctx, st.get("thesis"), own=own, max_words=LIMITS["pm_thesis"], source="pm.thesis",
scope=_scope([p for p in pts if p["n"] in rest]), rests=rest,
named=bool(_labels(st.get("thesis_rests_on"))))
return {"schema": SCHEMA, "role": "pm", "format": "v2", "thesis": thesis, "thesis_rests_on": rest if thesis else [],
"points": pts}, d1 + d2
def validate_summary(ctx, summ, *, own, debate_ids=None, debate_texts=()):
"""The executive summary: a figure-free text and view-first items citing only debate evidence, each in the
summary's own words (a verbatim copy of a debate object would be printed twice)."""
summ = summ or {}
drops, items = [], {}
if debate_ids is not None and not debate_ids:
return {"schema": SCHEMA, "role": "summary", "format": "v2", "thesis": None, "items_thesis": [], "items_risks": []}, \
([{"source": "summary", "reason": "NO_ALLOWED_EVIDENCE: the validated debate cited no evidence", "raw": summ}]
if summ else [])
for key in ("thesis", "risks"):
out = []
for i, it in enumerate(summ.get(key) if isinstance(summ.get(key), list) else []):
src = f"{key}.{i}"
if len(out) >= LIMITS["items"]:
break
if not isinstance(it, dict):
drops.append({"source": src, "reason": "SCHEMA: not an object", "raw": it})
continue
ev, view = _evidence_of(it), _view_of(it)
rs = evidence_reasons(ctx, ev, None, allowed=(set(debate_ids) if debate_ids is not None else None)) + \
text_reasons(ctx, view, max_words=LIMITS["item"])
if not rs and any(_norm(view) == _norm(d) for d in debate_texts or ()):
rs.append("COPIES_DEBATE: write the reason in the summary's own words")
if not rs:
rs = direction_reasons(ctx, view, ev, own)
if rs:
drops.append({"source": src, "reason": "; ".join(rs)[:400], "raw": it})
continue
out.append({"n": len(out) + 1, "kind": None, "evidence": ev, "view": view})
items[key] = out
alli = items["thesis"] + items["risks"]
text, d0 = validate_thesis(ctx, summ.get("summary"), own=own, max_words=LIMITS["summary"], source="summary",
scope=_scope(alli), rests=[1] if alli else [])
return {"schema": SCHEMA, "role": "summary", "format": "v2", "thesis": text, "items_thesis": items["thesis"],
"items_risks": items["risks"]}, d0 + drops
# ── rendering: every figure from a typed claim ────────────────────────────────────────────────────────────────
def _perimeter_note(state):
ev = ((state.historical.get("perimeter") or {}).get("event") or {})
return f"before the separation of {ev.get('business')}" if ev.get("business") else None
_READER = { # claim id -> (text before the value, text after the value); qualifiers are added from the state
"REVENUE_TTM": ("trailing revenue ", ""), "OPERATING_MARGIN_TTM": ("trailing operating margin ", ""),
"NET_MARGIN_TTM": ("trailing net margin ", ""), "NET_INCOME_TTM": ("trailing net income ", ""),
"OPERATING_INCOME_TTM": ("trailing operating income ", ""), "EPS_ADJUSTED_TTM": ("trailing adjusted EPS ", ""),
"EPS_GAAP_TTM": ("trailing reported EPS ", ""),
"BEAT_RATE": ("EPS beat estimates in ", " of recent quarters"),
"ADJ_GAAP_BRIDGE": ("adjusted EPS at ", " times reported EPS (trailing)"),
"FORECAST_REVENUE": ("the engine's revenue forecast for the next twelve months ", ""),
"FORECAST_REVENUE_GROWTH": ("the engine's forecast revenue growth for the next twelve months ", ""),
"FORECAST_OPERATING_MARGIN": ("the engine's operating margin forecast for the next twelve months ", ""),
"FORECAST_EPS": ("the engine's EPS forecast for the next twelve months ", ""),
"CONSENSUS_EPS_FY1": ("consensus EPS for the current fiscal year ", ""),
"CONSENSUS_EPS_FY2": ("consensus EPS for the next fiscal year ", ""),
"CONSENSUS_REVENUE_FY1": ("consensus revenue for the current fiscal year ", ""),
"CONSENSUS_REVENUE_NTM": ("consensus revenue for the next twelve months ", ""),
"ANALYST_TARGET": ("mean analyst price target ", ""), "PRICE": ("share price ", ""),
"MARKET_CAP": ("market capitalisation ", ""), "DIVIDEND_YIELD": ("dividend yield ", ""),
"PE_FORWARD": ("forward P/E ", ""), "PEG": ("forward PEG ", ""),
"INTRINSIC_VALUE": ("story value ", ""), "MECHANICAL_VALUE": ("mechanical DCF value ", ""),
"MARKET_COMPOSITE": ("market composite ", ""), "LIFECYCLE_STAGE": ("life-cycle stage ", ""),
"LIFECYCLE_FORWARD_GROWTH": ("consensus revenue growth for the next fiscal year ", ""),
"LIFECYCLE_TRAILING_GROWTH": ("trailing revenue growth ", ""),
"BE_REVENUE": ("revenue the price needs at maturity ", ""), "BE_MARGIN": ("operating margin the price needs ", ""),
"STORY_MARGIN": ("story maturity operating margin ", ""), "STORY_TAM": ("story market size at maturity ", ""),
"STORY_SHARE": ("story peak market share ", ""), "STORY_REVENUE_MATURITY": ("story revenue at maturity ", ""),
"STORY_WACC": ("initial cost of capital ", ""), "STORY_WACC_STABLE": ("terminal (stable-period) cost of capital ", ""),
"STORY_TERMINAL_GROWTH": ("terminal growth ", ""), "STORY_FAILURE": ("failure probability ", ""),
"STORY_START_MARGIN": ("continuing-company starting operating margin ", ""),
"STORY_START_REVENUE": ("continuing-company starting revenue ", ""),
"DMD_BETA": ("bottom-up beta ", ""), "DMD_MARGIN": ("industry operating margin ", " (Damodaran industry data)"),
"DMD_INDUSTRY": ("industry ", ""), "DMD_ERP": ("equity risk premium ", ""), "DMD_S2C": ("industry sales-to-capital ", ""),
"SEGMENT_COUNT_CONTINUING": ("", " businesses in the continuing company"), "CEO": ("chief executive ", ""),
"CFO": ("chief financial officer ", ""), "SMA200": ("200-day average ", ""), "MOMENTUM": ("momentum ", ""),
"RETURN_12M_PHRASE": ("shares ", ""), "RETURN_3M_PHRASE": ("shares ", ""),
}
_TRAILING = ("REVENUE_TTM", "OPERATING_MARGIN_TTM", "NET_MARGIN_TTM", "NET_INCOME_TTM", "OPERATING_INCOME_TTM",
"EPS_ADJUSTED_TTM", "EPS_GAAP_TTM", "ADJ_GAAP_BRIDGE", "LIFECYCLE_TRAILING_GROWTH")
def _fact(R, cid, state, cat):
"""Fragments for one fact: reader label + the claim fragment (+ perimeter qualifier)."""
cl = cat.get(cid)
if cl is None:
return []
if getattr(cl, "stype", None) == C.GUIDANCE:
v = cl.value if isinstance(cl.value, dict) else {}
rows = ((state.guidance.get("company") or {}).get("metrics")) or []
try:
label = str((rows[int(cid.split("_")[1]) - 1] or {}).get("label") or "").strip()
except (ValueError, IndexError):
label = ""
label = label or str(v.get("concept") or "guidance").lower().replace("_", " ")
basis = C.guidance_basis_label(cat, cid)
basis = None if (basis and basis.lower() in label.lower()) else basis # the row's label may name it already
tail = f" ({v.get('period_label')}{', ' + basis if basis else ''})" if v.get("period_label") else \
(f" ({basis})" if basis else "")
return [P.Fragment(f"company guidance, {label}: "), R.c(cid), P.Fragment(tail)]
pre, post = _READER.get(cid) or ((re.sub(r"\s*\([^)]*\)", "", cl.label or cid).strip() + " "), "")
if not post and pre.endswith(" "):
pre = pre.rstrip() + ": " # a noun label reads "label: value" (no verbless run-on)
note = _perimeter_note(state) if cid in _TRAILING else None
if note:
post = post + f" ({note})"
return [P.Fragment(pre), R.c(cid), P.Fragment(post)]
# the second and later mentions of a relation in one report: its subject (with the subject's own condition and period)
# and the relation's predicate or clause form (the first mention prints the full relation with both values). A relation
# whose condition is a value of its own (the break-even revenue "holding the operating margin at 45.0%") is never
# compacted: its compact forms drop that condition.
_SUBJECT = {"PRICE": ("The price ", "PREDICATE"), "ANALYST_TARGET": ("The mean analyst target ", "PREDICATE"),
"STORY_START_MARGIN": ("The continuing company's operating margin ", "PREDICATE"),
"FORECAST_EPS": ("The engine's EPS forecast for the next twelve months is ", "CLAUSE"),
"FORECAST_REVENUE": ("The engine's revenue forecast for the next twelve months is ", "CLAUSE"),
"BE_MARGIN": ("The operating margin today's price needs at the story's revenue is ", "CLAUSE")}
def _relation(R, rid, cat, rels, compact=False, cases_cited=False):
"""A relation as one sentence: the price relations in their predicate form after "The price", else the clause (or,
when the report has already printed it in full, its subject and predicate)."""
rel = rels.get(rid) or {}
if rel.get("lhs") == "PRICE" and cat.get(f"{rid}__PREDICATE") is not None:
return [P.Fragment("The price "), R.c(f"{rid}__PREDICATE"), P.Fragment(".")]
if rid == "REL_EXPECTATIONS_STORY" and cat.get(f"{rid}__COMPACT") is not None:
# the pointer to the valuation section only when the unit does not print the cases itself
return [R.c(f"{rid}__COMPACT", cap=True), P.Fragment("." if cases_cited else
" (the valuation section sets out the revenue and the margin "
"the price needs).")]
subj, form = _SUBJECT.get(rel.get("lhs"), (None, None))
if compact and subj and cat.get(f"{rid}__{form}") is not None:
return [P.Fragment(subj), R.c(f"{rid}__{form}"), P.Fragment(".")]
return [R.c(rid, cap=True), P.Fragment(".")]
def render_evidence(R, ids, state, cat, rels, seen=None):
"""Evidence fragments: relations as sentences; the remaining facts (minus any relation's own operands) as one.
``seen`` is the report's evidence registry: a relation printed in full earlier in the report is printed compactly."""
ids = list(dict.fromkeys(ids))
cited_rels = [e for e in ids if cat.get(e) is not None and cat[e].stype == C.REL]
operands = {x for rid in cited_rels for x in ((rels.get(rid) or {}).get("lhs"), (rels.get(rid) or {}).get("rhs")) if x}
out = []
case_ids = {"REL_BREAK_EVEN_REVENUE_STORY", "REL_BREAK_EVEN_MARGIN_STORY", "REL_BREAK_EVEN_REVENUE_GROWTH"}
for rid in cited_rels:
out += ([P.Fragment(" ")] if out else []) + _relation(R, rid, cat, rels, compact=seen is not None and rid in seen,
cases_cited=bool(case_ids & set(ids)))
if seen is not None:
seen.add(rid)
facts = [e for e in ids if e not in cited_rels and e not in operands]
parts = [_fact(R, e, state, cat) for e in facts]
parts = [p for p in parts if p]
if parts:
sent = []
for i, p in enumerate(parts):
sent += ([P.Fragment("; ")] if i else []) + p
first = sent[0]
if first.text and not first.claim_id:
sent[0] = P.Fragment(first.text[:1].upper() + first.text[1:])
out += ([P.Fragment(" ")] if out else []) + sent + [P.Fragment(".")]
return out
def evidence_plain(ids, cat, rels):
"""The evidence as plain text (for forwarding to the next round and for the semantic gate)."""
bits = []
for e in dict.fromkeys(ids):
cl = cat.get(e)
if cl is None:
continue
if cl.stype == C.REL:
bits.append(str(cl.rendered))
else:
pre, post = _READER.get(e) or ((re.sub(r"\s*\([^)]*\)", "", cl.label or e).strip() + " "), "")
pre = pre.rstrip() + ": " if not post and pre.endswith(" ") else pre
bits.append(f"{pre}{cl.rendered}{post}".strip())
return "; ".join(bits)
def forward_text(argset, cat, rels, *, indexed=False, label=None):
"""What the next round reads: the VALIDATED thesis and points only (never raw prose or risk flags)."""
if not argset:
return ""
lines = []
if argset.get("thesis"):
lines.append(f" Thesis: {P.TOKEN.sub('its own return', argset['thesis'])}")
for p in argset.get("points") or []:
tag = f"P{p['n']} " if indexed else "- "
ev = evidence_plain(p.get("evidence") or [], cat, rels)
lines.append(f" {tag}[{KIND_LABEL.get(p.get('kind'), 'Point')}] {p['view']}" + (f" (evidence: {ev})" if ev else ""))
head = f"{label}:\n" if label else ""
return head + "\n".join(lines)
def evidence_menu(ctx):
"""The author's evidence menu (replaces the token header): ids by kind, with values and meanings."""
lines = ["EVIDENCE MENU — cite these IDs in \"evidence\"; the report prints each one's figure and wording itself.",
"ID | value | meaning"]
for cid in sorted(ctx.offer):
cl = ctx.cat[cid]
meaning = re.sub(r"\s*\([^)]*complete relation[^)]*\)", "", cl.label or "")
basis = C.guidance_basis_label(ctx.cat, cid) if cl.stype == C.GUIDANCE else None
meaning += f" [{basis} basis]" if basis else ""
val = f"\"{cl.rendered}\"" if cl.stype == C.REL else str(cl.rendered)
lines.append(f" {cid} | {val} | {meaning}")
lines.append(f"KINDS (a label for what the point is about): {', '.join(ctx.kinds)}. Every point cites 1-3 IDs from this "
"menu that support it; two facts in one point need the relation that connects them; the two break-even "
"solves are alternatives, so cite REL_EXPECTATIONS_STORY to discuss them together.")
return "\n".join(lines)
# ── the semantic gate: does each judgement follow from its evidence? ──────────────────────────────────────────
GATE_VERDICTS = ("OK", "UNSUPPORTED", "CONTRADICTED", "MISATTRIBUTED", "EVIDENCE_MISUSE")
# Calibrated offline (2026-09-28) against 96 objects from four live SPGI runs labelled by three reviewers: this
# rubric (the reviewers' own standard, stated generally) caught 67-71% of defective objects against 52-62% for the
# previous one, at 11-15% of good objects removed (5-8% before); published defect rate after the gate 8.6-9.5%
# (10-13% before). scratchpad calib/ holds the corpus, prompts and scores.
GATE_RUBRIC = """You check an equity report before it is published, to the standard its reviewers apply. Each OBJECT below is
a short judgement written by an analyst persona, printed next to the EVIDENCE it cites (figures rendered by the system
from verified data). An investor reads the words as claims. Decide for EACH object whether an investor reading it would
be misled or the report embarrassed.
verdict:
- CONTRADICTED: the evidence, the FACTS or the REST OF THE REPORT contradict it: a wrong direction, or a universal or
absolute claim ("no company sustains ...", "impossible", "never", a "premium" or "discount" against peers) that any
figure printed in the report refutes. Check such claims against every table in the report, not only the object's
evidence.
- UNSUPPORTED: it asserts as FACT something material that no evidence, fact or call line shows: a cause, motive or
intention read from a price series (momentum or a moving average do not show what investors think or plan); a claim
about a quantity none of its evidence measures (a comparison with consensus for a metric that has no consensus
figure); closeness or distance ("near", "far above") to a figure it does not cite; events, trends or history not in
the brief.
- MISATTRIBUTED: it presents the model's own assumptions or forecasts as the company's plans or guidance, or says
management, the company or the call said something the call excerpt does not show that speaker saying.
- EVIDENCE_MISUSE: it draws a conclusion its evidence cannot carry: figures of a different period or business perimeter,
an industry aggregate as the company's own figure, ownership counts as conviction.
- OK: honest opinion or judgement that is hedged or plainly a view ("looks demanding", "appears attainable", "if margins
stall ...") and is consistent with the evidence and the report. Do not remove a view only because it is strongly
worded or because you would weigh the evidence differently.
One short reason per object. Unless the verdict is OK, "quote" gives the exact words of that object's own "text" the
verdict is about."""
# thesis pass recalibrated (2026-09-28) on 32 labelled theses from six runs: the objects-rubric wording removed 28%
# of sound theses (the live gate emptied the opening cases); this wording removed 11% at 75% precision, strict on
# contradictions and invented specifics, lenient on characterisation the report's figures bear out.
GATE_THESIS_RUBRIC = """You check an equity report before it is published. Each OBJECT below is an analyst persona's THESIS: a short
summary of its case. "evidence" lists the figures the system printed for the author's validated points; the author may
also draw on any figure printed elsewhere in the REPORT. Judge ONLY the thesis text. Summarising and characterising is
a thesis's job.
verdict:
- CONTRADICTED: a figure in its evidence, the FACTS or the REPORT contradicts it: a wrong direction (upside against a
value the price already exceeds; cheap where every anchor shows a premium), or an absolute claim that a printed figure
refutes.
- UNSUPPORTED: it asserts a specific material fact that nothing printed in the report supports: an event, a motive or
intention nobody states, a problem the report's figures do not show, or plans for the company that its guidance does
not contain.
- MISATTRIBUTED: it attributes words or plans to management or the call that the call excerpt does not show.
- OK: a summary judgement or characterisation consistent with the report, including general claims its figures bear
out (a high-quality franchise where margins are high, capital returns where return guidance is printed, a demanding
valuation where the price sits above value). Strong wording alone is not a reason to remove.
Be strict about contradictions and invented specifics, and lenient about characterisation. One short reason per object.
Unless the verdict is OK, "quote" gives the exact words of that thesis's own "text"."""
def _rest_objects(role, a):
"""The validated objects a thesis rests on (all items for the summary; all points when no list was recorded)."""
if role == "summary":
return (a.get("items_thesis") or []) + (a.get("items_risks") or [])
pool = (a.get("points") or []) + (a.get("answers") or [])
if "thesis_rests_on" not in a:
return pool
want = set(a.get("thesis_rests_on") or [])
return [p for p in pool if p.get("n") in want]
def gate_objects(sets, cat, rels, phase="objects"):
"""[{id, author, text, evidence}]: phase "objects" = every point, item and answer; phase "theses" = every thesis,
with the surviving points it rests on as its evidence (judged after the objects, so a thesis is never approved
over points the same gate removed)."""
out = []
for role, a in (sets or {}).items():
if not a:
continue
if phase == "theses":
if a.get("thesis"):
# the figures its points cite, never the points' own wording (the live gate judged a point's words and
# removed the thesis for them: run 515a67a71150dd12)
ids = list(dict.fromkeys(e for p in _rest_objects(role, a) for e in p.get("evidence") or []))
out.append({"id": f"{role}:thesis", "author": role, "text": P.TOKEN.sub("its own return", a["thesis"]),
"evidence": evidence_plain(ids, cat, rels) or "(no evidence)"})
continue
for key in ("points", "items_thesis", "items_risks"):
for p in a.get(key) or []:
out.append({"id": f"{role}:{key}:{p['n']}", "author": role, "text": p["view"],
"evidence": evidence_plain(p.get("evidence") or [], cat, rels)})
for i, x in enumerate(a.get("answers") or []):
out.append({"id": f"{role}:answers:{i + 1}", "author": role,
"text": f"(answering the {x['to_role']}'s point \"{x['to_view']}\", verdict {x['verdict']}) {x['view']}",
"evidence": evidence_plain(x.get("evidence") or [], cat, rels)})
return out
def gate_prompt(objects, facts_text, excerpt, report_text=None, phase="objects"):
import json as _json
return ((GATE_THESIS_RUBRIC if phase == "theses" else GATE_RUBRIC)
+ "\n\nFACTS (verified, rendered by the system):\n" + facts_text
+ ("\n\nTHE REST OF THE REPORT (printed by the system: every figure the reader sees beside these objects):\n"
+ report_text[:16000] if report_text else "")
+ "\n\nCALL EXCERPT (the end of the latest earnings call; speaker names precede their words):\n"
+ (excerpt or "(none)")[:6000]
+ "\n\nOBJECTS:\n" + "\n".join(_json.dumps(o, ensure_ascii=False) for o in objects)
+ '\n\nRespond with ONLY this JSON: {"verdicts": [{"id": "<object id>", "verdict": "OK|UNSUPPORTED|CONTRADICTED|'
'MISATTRIBUTED|EVIDENCE_MISUSE", "quote": "<unless OK: the exact words of this object\'s text>", '
'"reason": "<one short sentence>"}]} with one entry per object.')
def facts_text(ctx):
"""The verified facts a gate reads: every offered claim, the relations, the assessments and the perimeter."""
lines = [f"- {cid}: {ctx.cat[cid].rendered} — {ctx.cat[cid].label}" for cid in sorted(ctx.offer)]
for cid, a in ((ctx.rules.assessments or {}).items()):
lines.append(f"- assessment of {cid}: {a.get('assessment')} ({a.get('basis')})")
note = _perimeter_note(ctx.state)
if note:
lines.append(f"- trailing (twelve-month) figures are {note}; forecasts and the story describe the continuing company")
return "\n".join(lines)
def _attributed(quote, text):
"""A non-OK verdict names the words it is about; they must be the object's own (typed lineage of the verdict)."""
q = _norm(quote)
return bool(q) and q in _norm(text)
def apply_gate(sets, verdicts, phase="objects", unattributed=None):
"""Remove every object of this phase the gate did not mark OK (a missing verdict is not OK); objects of the other
phase are untouched. A non-OK verdict whose quote is not in the object's own text is about something else: the
object is kept and its id is added to ``unattributed`` (the caller asks once more for it alone). Returns
(sets, drops)."""
vmap = {}
for v in verdicts or []:
if isinstance(v, dict) and isinstance(v.get("id"), str):
vmap[v["id"]] = (str(v.get("verdict") or "").upper(), str(v.get("reason") or "")[:200], v.get("quote"))
unattributed = unattributed if unattributed is not None else []
def verdict(oid, text):
vd = vmap.get(oid, ("MISSING", "no verdict", None))
if vd[0] in ("OK", "MISSING") or _attributed(vd[2], text):
return vd
unattributed.append(oid)
return ("OK", "", None)
drops, out = [], {}
for role, a in (sets or {}).items():
if not a:
out[role] = a
continue
a = dict(a)
if phase == "theses":
if a.get("thesis"):
vd = verdict(f"{role}:thesis", a["thesis"])
if vd[0] != "OK":
drops.append({"source": f"{role}.thesis", "reason": f"SEMANTIC_{vd[0]}: {vd[1]}", "raw": a["thesis"]})
a["thesis"] = None
if "thesis_rests_on" in a:
a["thesis_rests_on"] = []
out[role] = a
continue
for key in ("points", "items_thesis", "items_risks"):
kept = []
for p in a.get(key) or []:
vd = verdict(f"{role}:{key}:{p['n']}", p["view"])
if vd[0] == "OK":
kept.append(p)
else: # P<n>: the validated label (typed drops name the author's raw position instead)
drops.append({"source": f"{role}.{key}.P{p['n']}", "reason": f"SEMANTIC_{vd[0]}: {vd[1]}", "raw": p})
if key in a:
a[key] = kept
if a.get("answers"):
kept = []
for i, x in enumerate(a["answers"]):
vd = verdict(f"{role}:answers:{i + 1}", x["view"])
if vd[0] == "OK":
kept.append(x)
else:
drops.append({"source": f"{role}.answers.A{i + 1}", "reason": f"SEMANTIC_{vd[0]}: {vd[1]}", "raw": x})
a["answers"] = kept
out[role] = a
out, d2 = drop_orphan_answers(out)
return out, drops + d2
def drop_orphan_answers(sets):
"""An answer whose target point was removed no longer answers anything the reader can see: drop it (recorded)."""
out, drops = dict(sets or {}), []
for role, a in list(out.items()):
if not a or not a.get("answers"):
continue
tgt = out.get(a["answers"][0].get("to_role")) or {}
visible = {f"P{p['n']}" for p in tgt.get("points") or []}
kept = [x for x in a["answers"] if x["to"] in visible]
drops += [{"source": f"{role}.answers.{x.get('to')}", "reason": "TARGET_REMOVED: the point it answers is not "
"published", "raw": x} for x in a["answers"] if x["to"] not in visible]
if len(kept) != len(a["answers"]):
out[role] = dict(a, answers=kept)
return out, drops
def orphan_theses(sets):
"""Drop every thesis whose points were all removed after it validated (typed revalidation or the gate); keep the
rest list to the survivors (after dropping answers to removed points). Returns (sets, drops)."""
sets, drops = drop_orphan_answers(sets)
out = {}
for role, a in (sets or {}).items():
if not a or not a.get("thesis"):
out[role] = a
continue
a = dict(a)
if role == "summary":
alive = bool((a.get("items_thesis") or []) + (a.get("items_risks") or []))
elif "thesis_rests_on" in a:
pool = {p.get("n") for p in (a.get("points") or []) + (a.get("answers") or [])}
a["thesis_rests_on"] = [n for n in a.get("thesis_rests_on") or [] if n in pool]
alive = bool(a["thesis_rests_on"])
else:
alive = True
if not alive:
drops.append({"source": f"{role}.thesis", "reason": "THESIS_ORPHANED: every point it rests on was removed",
"raw": a["thesis"]})
a["thesis"] = None
out[role] = a
return out, drops
# ── one bounded repair turn: the author sees its removed objects with the typed reasons ────────────────────────────
_NOT_REPAIRABLE = ("INTERNAL_ERROR", "OVERFLOW", "SEMANTIC_", "REBUTTAL_NO_NUMBERS", "REVALIDATION", "NO_TARGETS",
"NO_ALLOWED_EVIDENCE", "TARGET_REMOVED")
def repairable(drops):
"""The drops an author can fix by re-authoring (typed reasons); never a crash, an overflow or a gate verdict."""
return [d for d in drops or [] if d.get("reason") and not str(d["reason"]).startswith(_NOT_REPAIRABLE)]
def repair_prompt(original_prompt, structured, drops):
"""The original task, the author's previous structured reply and each removed object with the rule it broke. The
reply to this is validated exactly like the first one; code never edits an object."""
import json as _json
lines = [f"- {d.get('source')}: {str(d.get('reason'))[:300]}\n was: "
+ _json.dumps(d.get("raw"), ensure_ascii=False, default=str)[:400] for d in drops]
return (original_prompt + "\n\n=== REPAIR (one attempt) ===\nYOUR PREVIOUS REPLY (its structured part):\n"
+ _json.dumps(structured or {}, ensure_ascii=False, default=str)[:6000]
+ "\n\nTHESE OBJECTS WERE REMOVED, WITH THE RULE EACH BROKE:\n" + "\n".join(lines)
+ "\n\nReturn the COMPLETE JSON again, in the same format as before. Keep every number exactly as before. "
"Rewrite each removed object so it meets the rule named, or leave it out; keep the objects that were not "
"removed as they were, and renumber thesis_rests_on to the positions in your new reply.")
def object_count(argset):
"""How many publishable objects an argument set holds."""
a = argset or {}
return (1 if a.get("thesis") else 0) + sum(len(a.get(k) or []) for k in ("points", "answers", "items_thesis",
"items_risks"))
# ── what the portfolio manager is told about each seat's arguments (the semantic gate runs before the PM round) ──
SEAT_SETS = {"neutral": ("neutral",), "bear": ("bear_open", "bear"), "bull": ("bull_open", "bull")}
SEAT_ROLES = tuple(r for roles in SEAT_SETS.values() for r in roles)
def supporting_count(argset):
"""The supporting arguments an argument set holds: its points and answers (a thesis is a conclusion, not support)."""
a = argset or {}
return len(a.get("points") or []) + len(a.get("answers") or [])
def seat_evidence_state(sets, drops=()):
"""{seat: {accepted, rejected}}: the supporting arguments each seat still has after typed validation and the
semantic gate, and how many were removed (first attempts superseded by a repair are not counted)."""
out = {}
for seat, roles in SEAT_SETS.items():
rej = 0
for d in drops or ():
src = str((d or {}).get("source") or "")
if src.startswith(("attempt1:", "repair")) or src.endswith(".thesis"):
continue
if src.split(".")[0].split("_")[0] == seat:
rej += 1
out[seat] = {"accepted": sum(supporting_count((sets or {}).get(r)) for r in roles), "rejected": rej}
return out
def evidence_state_line(seat, st):
"""One line of the PM's brief: the seat's evidence state, stated explicitly when nothing survived."""
n, m = int((st or {}).get("accepted") or 0), int((st or {}).get("rejected") or 0)
removed = f"{m} removed by evidence validation" if m else "none removed"
if n == 0:
return (f" EVIDENCE STATE: {seat.capitalize()} seat has no accepted supporting arguments ({removed}). Its scenario "
"return stands without accepted support.")
return f" EVIDENCE STATE: {n} supporting argument{'s' if n != 1 else ''} accepted ({removed})."
def repair_keeps(first, repaired):
"""A repair is kept only when it loses nothing in ANY class: the thesis (and summary text) stays if it was there,
and no list of points, answers or items gets shorter (a total count could trade a thesis for a point)."""
a, b = first or {}, repaired or {}
if a.get("thesis") and not b.get("thesis"):
return False
if any(len(b.get(k) or []) < len(a.get(k) or []) for k in ("points", "answers", "items_thesis", "items_risks")):
return False
return object_count(b) > object_count(a)
# ── revalidation at compose / audit: the same checks, on the stored validated objects ─────────────────────────
def revalidate(ctx, argset, *, own):
"""Re-run the typed checks on a stored argument set (compose and the report audit). Returns (argset, drops)."""
if not argset:
return argset, []
a, drops = dict(argset), []
role = a.get("role") or "?"
for key in ("points", "items_thesis", "items_risks"):
if key not in a:
continue
kept = []
for p in a.get(key) or []:
kind = p.get("kind")
rs = (evidence_reasons(ctx, p.get("evidence") or [], kind) if kind else
evidence_reasons(ctx, p.get("evidence") or [], None, allowed=None)) + \
text_reasons(ctx, p.get("view"), max_words=LIMITS["view"])
if not rs:
rs = direction_reasons(ctx, p["view"], p.get("evidence") or [], own)
(drops.append({"source": f"{role}.{key}.{p.get('n')}", "reason": "REVALIDATION: " + "; ".join(rs)[:380],
"raw": p}) if rs else kept.append(p))
a[key] = kept
if a.get("answers"):
kept = []
for x in a["answers"]:
rs = (evidence_reasons(ctx, x.get("evidence") or [], None, required=(x.get("verdict") == "FAILS"),
max_ids=LIMITS["answer_evidence"]) or cross_reasons(ctx, x.get("evidence") or [],
x.get("to_evidence") or [])) + \
text_reasons(ctx, x.get("view"), max_words=LIMITS["view"])
if not rs:
rs = direction_reasons(ctx, x["view"], (x.get("evidence") or []) + (x.get("to_evidence") or []), own)
(drops.append({"source": f"{role}.answers.{x.get('to')}", "reason": "REVALIDATION: " + "; ".join(rs)[:380],
"raw": x}) if rs else kept.append(x))
a["answers"] = kept
if a.get("thesis"):
lim = LIMITS["pm_thesis"] if role == "pm" else LIMITS["summary"] if role == "summary" else LIMITS["thesis"]
rest = _rest_objects(role, a)
rests = None if (role != "summary" and "thesis_rests_on" not in a) else [p.get("n") or 1 for p in rest]
t, d = validate_thesis(ctx, a["thesis"], own=own, max_words=lim, source=f"{role}.thesis", scope=_scope(rest),
rests=rests)
a["thesis"], drops = t, drops + d
if "thesis_rests_on" in a:
a["thesis_rests_on"] = [p.get("n") for p in rest] if t else []
return a, drops