Download engine/prose.py from s-ttp/crucible: direct link, hf CLI and curl.
- Browser
- Download file 106 kB
-
https://huggingface.co/spaces/s-ttp/crucible/resolve/main/engine/prose.py
- Command line
-
hf download hf://spaces/s-ttp/crucible/engine/prose.py
-
curl -L -o prose.py https://huggingface.co/spaces/s-ttp/crucible/resolve/main/engine/prose.py
106 kB
| """Prose ingestion: LLM text → claim-tagged fragments, in ONE pass, with no rewriting. | |
| LLMs write explanation; they do not author numbers. Every figure in persona, PM and summary text | |
| must be a ``[[CLAIM_ID]]`` token, which is replaced by the claim's rendered value and keeps the | |
| claim's identity as a fragment. A sentence is REJECTED (removed, and recorded for internal QA) | |
| when it | |
| * contains a material number typed by the model instead of a token, | |
| * uses an unknown token, | |
| * places a claim next to a label that claim forbids (e.g. "the headline return of [[PM_RETURN]]"), | |
| * uses valuation vocabulary the selected engine forbids, | |
| * leans on a quantitative model whose reliability does not permit it as evidence, | |
| * describes scheduled Rule 10b5-1 sales as conviction or a governance problem, | |
| * names an executive who is not the canonical officer for that role. | |
| Nothing is rewritten. There is no second correction pass, so there is no correction loop: a | |
| sentence is either accepted as written or removed. | |
| """ | |
| from __future__ import annotations | |
| import re | |
| from units import format_percent | |
| from dataclasses import dataclass, field | |
| TOKEN = re.compile(r"\[\[([A-Z0-9_]+)\]\]") | |
| # a material number typed by the model: currency, percentages, multiples, basis points, decimals, | |
| # thousands-separated figures, magnitudes | |
| MATERIAL_NUMBER = re.compile( | |
| r"(\$\s?\d)|(\d\s?%)|(\b\d+(?:\.\d+)?\s?x\b)|(\b\d+(?:\.\d+)?\s?times\b)|(\b\d+\s?bps\b)|" | |
| r"(\b\d+\.\d+\b)|(\b\d{1,3}(?:,\d{3})+\b)|(\b\d+(?:\.\d+)?\s?(?:bn|billion|million|mn|trillion)\b)|" | |
| r"(\bpercent\b)|(\bpercentile\b\s*\d)|(\d+(?:st|nd|rd|th)\s+percentile)|" | |
| # basis points / percentage points / turns written out, and bare quantitative integers of two or more digits | |
| r"(\b\d+\s*(?:basis\s+points?|bps?|percentage\s+points?|points?|pts|turns?|notches)\b)|((?<![\w.$/\-\u2010-\u2013])\d{2,}(?![\w%/\-\u2010-\u2013]))|" | |
| # magnitudes written as words: "doubled", "twice", "half" (not "the second half of the year") | |
| r"(\b(?:doubl(?:e|ed|es|ing)|tripl(?:e|ed|es|ing)|quadrupl\w*|halv(?:e|ed|es|ing)|twice|thrice|double-digit|triple-digit)\b)|" | |
| r"((?<!first )(?<!second )(?<!back )(?<!front )(?<!latter )(?<!former )\bhalf\b(?!\s+(?:of\s+)?(?:the\s+)?(?:year|fiscal|calendar|decade)))", re.I) | |
| # non-material numerals that may stay: years, quarter labels, horizons, fiscal-year labels | |
| BENIGN = re.compile(r"\b(?:19|20)\d{2}\b|\bQ[1-4]\b|\bFY\s?\d{2,4}\b|\b\d{1,3}[-\u2010\u2011\u2012\u2013\s](?:month|year|quarter|day|week)s?\b|" | |
| r"\b\d{1,2}\s+(?:months?|years?|quarters?|days?|analysts?|peers?|storytellers?|seats?)\b|" | |
| r"\b10b5-1\b|\bRule\s+10b5-1\b|\bForm\s+4\b|\b8-K\b|\b10-[KQ]\b|\b20-F\b|\b6-K\b|\bItem\s+\d\.\d+\b|" | |
| r"\bS&P\s+500\b|\bNasdaq(?:-|\s)100\b|\bRussell\s+(?:1000|2000|3000)\b|\bDow\s+30\b|\b13F\b|\bExhibit\s+99\.1\b", re.I) | |
| SENT = re.compile(r"(?<=[.!?])\s+(?=[A-Z\[“\"])") | |
| QUANT_TOPIC = re.compile(r"\b(quant(?:itative)? model|price model|machine[- ]learning|\bML\b|panel[- ]ML|cross-sectional " | |
| r"(?:rank|model|screen)|factor model|backtest\w*|percentile|decile)\b", re.I) | |
| # the same words, split by the model they name: each model's permission applies to its own words only | |
| PANEL_TOPIC = re.compile(r"\b(panel[- ]ML|cross-sectional (?:rank|model|screen)|percentile|decile)\b", re.I) | |
| PRICE_MODEL_TOPIC = re.compile(r"\b(quant(?:itative)? model|price model|machine[- ]learning|\bML\b|factor model|backtest\w*)\b", re.I) | |
| PLAN_SALE_BAD = (r"governance\s+(?:drag|concern|risk|issue|red\s+flag)", | |
| r"(?:bearish|negative)\s+(?:conviction|management\s+signal)", | |
| r"(?:management|the\s+CEO|the\s+CFO|insiders?|executives?)\s+(?:is|are)\s+(?:exiting|bailing|fleeing|dumping)", | |
| r"informed\s+(?:capital|money|selling)", r"smart\s+money\s+is\s+selling", r"loss\s+of\s+confidence") | |
| ROLE_NAME = re.compile(r"\b(CEO|CFO|COO|chief (?:executive|financial|operating) officer)\s*,?\s+((?:[A-Z][a-z]+\.?\s?){1,3}[A-Z][a-z]+)") | |
| # company guidance may be described only by citing a verified GUIDANCE_n claim (guidance.py) | |
| GUIDANCE_WORDS = re.compile(r"\b(guidance|guided|guides|reaffirm\w*|(?:company|management|its|the company's|management's)\s+" | |
| r"(?:outlook|expects?|forecasts?|projects?|targets?)|(?:raised|lowered|cut|lifted|maintained)\s+" | |
| r"(?:its|the|full-year|annual)\s+(?:outlook|forecast|targets?))\b", re.I) | |
| GUIDANCE_TOKEN = re.compile(r"\[\[GUIDANCE_\d+\]\]") | |
| # a Form 4 or a 13F records what was held or traded, never why: no motive or intent may be attributed | |
| MOTIVE_ACTOR_INSIDER = re.compile(r"\b(insiders?|insider\s+(?:selling|buying|sales|purchases|activity|transactions)|Form\s+4)\b", re.I) | |
| # management and executives are insider actors only when the sentence is about their own trades (not an | |
| # earnings-call remark): "the CFO's purchases signal confidence" is a motive inference, "management expressed | |
| # confidence on the call" is a reported statement | |
| MOTIVE_ACTOR_MGMT = re.compile(r"\b(management|executives?|directors?|the\s+CEO|the\s+CFO|officers?)\b", re.I) | |
| TRADE_WORD = re.compile(r"\b(sell\w*|sold|sales?|buy\w*|bought|purchas\w*|stake|holdings?|shares|10b5-1|dispos\w*)\b", re.I) | |
| MOTIVE_ACTOR_13F = re.compile(r"\b(institutions?|institutional\s+(?:investors?|holders?|ownership)|funds?|hedge\s+funds?|" | |
| r"tracked\s+(?:long-term\s+)?investors?|long-term\s+investors?|smart\s+money|guru\w*|holders?|13F|" | |
| r"super-?investors?)\b", re.I) | |
| MOTIVE_WORD = re.compile(r"\b(conviction|confiden(?:ce|t)|vote\s+of\s+(?:no\s+)?confidence|betting|bet\s+on|believes?|believed|" | |
| r"bullish|bearish|doubts?|doubting|sees?\s+value|knows?|informed|insight|signal(?:s|ed|ing|led|ling)?\s+" | |
| r"(?:that|a|an|their|its|confidence|conviction|doubt|belief|optimism|pessimism|caution|concern)|" | |
| r"optimis\w*|pessimis\w*|endorse\w*|vote\s+with\s+their\s+feet|skin\s+in\s+the\s+game)\b", re.I) | |
| MOTIVE_WINDOW = 70 | |
| # ── comparisons between two claims (checked with compatible(), not by wording) ── | |
| COMPARATIVE = re.compile(r"\b(vs\.?|versus|above|below|higher than|lower than|exceed\w*|lag\w*|premium|discount|richer|" | |
| r"faster|quicker|stronger than|weaker than|outgrow\w*|more than|less than|" | |
| r"cheaper|better than|worse than|compared (?:with|to)|against|outpac\w*|trail\w*|ahead of|behind|" | |
| r"decelerat\w*|accelerat\w*|slow\w*|speed\w* up|down from|up from|drops? to|falls? to|rises? to|" | |
| r"expands? (?:from|to)|contracts? (?:from|to))\b", re.I) | |
| # ── evidence that may not carry a direction (13F positions; immaterial or ineligible insider trades) ── | |
| DIRECTIONAL = re.compile(r"\b(accumulat\w*|distribution|floor|ceiling|support\w*|cushion\w*|tailwind|headwind|bullish|bearish|" | |
| r"upside|downside|thinning|deepening|confirm\w*|validat\w*|endorse\w*|backing|vote|conviction|" | |
| r"smart money|flows? point|point(?:s)? to|signal\w*|reassur\w*|red flag|warning sign)\b", re.I) | |
| EVIDENCE_13F = re.compile(r"\[\[OWN_13F_[A-Z_]+\]\]|\b(13F|institutions?|institutional|holders?|tracked (?:long-term )?investors?|" | |
| r"ownership|breadth|super-?investors?|funds? (?:hold|own|add|bought|sold))\b", re.I) | |
| EVIDENCE_INSIDER = re.compile(r"\[\[INSIDER_[A-Z_]+\]\]|\b(insiders?|insider (?:buying|selling|purchases|sales|activity)|Form 4)\b", re.I) | |
| # ── relative valuation against peers ── | |
| RELVAL = re.compile(r"\b(rich(?:ly|er)?|cheap(?:ly|er)?|expensive|inexpensive|premium|discount|undervalued|overvalued|" | |
| r"stretched|lofty|bargain)\b", re.I) | |
| PEER_WORDS = re.compile(r"\b(peers?|peer group|competitors?|rivals?|comparables?|comps|the sector|sector peers|industry peers)\b", re.I) | |
| _REL_METRIC = {"pe": r"p/e|\bpe\b|price[- ]to[- ]earnings|earnings multiple|\bearnings\b", | |
| "ps": r"p/s|price[- ]to[- ]sales|sales multiple|\bsales\b", "ev_ebitda": r"ebitda", | |
| "fcf_yield": r"free[- ]cash[- ]flow yield|fcf yield|\bfcf\b"} | |
| def comparison_violation(sentence, compare_fn): | |
| """(claim_a, claim_b, reasons) for the first pair of consecutive claims joined by a comparative construct that | |
| compatible() rejects; None otherwise.""" | |
| if compare_fn is None: | |
| return None | |
| toks = list(TOKEN.finditer(sentence)) | |
| for x, y in zip(toks, toks[1:]): | |
| between = sentence[x.end():y.start()] | |
| before = sentence[max(0, x.start() - 40):x.start()] | |
| comparative = COMPARATIVE.search(between) or (re.search(r"\bfrom\b", before, re.I) and re.search(r"\bto\b", between, re.I)) | |
| if not comparative: | |
| continue | |
| r = compare_fn(x.group(1), y.group(1)) | |
| if r is not None and not r[0]: | |
| return x.group(1), y.group(1), r[1] | |
| return None | |
| TREND_VERB = re.compile(r"\b(decelerat\w*|re-?accelerat\w*|accelerat\w*|slow(?:s|ed|ing|down|er)?|speed\w* up|" | |
| r"pick(?:s|ed|ing)? up|moderat(?:e|es|ed|ing))\b", re.I) | |
| PERIOD_DESCRIPTOR = re.compile(r"\b(trailing|forward)\b", re.I) | |
| def implicit_comparison_violation(sentence, counterparts, descriptors=None): | |
| """(claim, counterpart, reasons) when a sentence cites one claim and compares it with an incomparable counterpart | |
| it does not cite: by naming the counterpart's period or perimeter next to a comparative ("[[X]] below trailing"), | |
| or by describing a trend in a growth rate whose historical counterpart is incomparable ("decelerating").""" | |
| if not counterparts: | |
| return None | |
| cited = {m.group(1) for m in TOKEN.finditer(sentence)} | |
| res = PERIOD_DESCRIPTOR.sub(" ", TOKEN.sub(" ", sentence)) # "trailing" names a period, it is not "trails" | |
| for cid in cited: | |
| for other, why, words, trend in counterparts.get(cid, ()): | |
| if other in cited: | |
| continue # an explicit pair: comparison_violation decides | |
| if trend and TREND_VERB.search(res): | |
| return cid, other, why | |
| fam = (descriptors or {}).get(other, (None, ()))[0] | |
| owned = {w for c in cited for f, ws in [(descriptors or {}).get(c, (None, ()))] if f == fam for w in ws} | |
| hit = [w for w in words if w not in owned and re.search(r"\b" + re.escape(w) + r"\b", TOKEN.sub(" ", sentence), re.I)] | |
| if hit: | |
| rest = res | |
| for w in hit: | |
| rest = re.sub(r"\b" + re.escape(w) + r"\b", " ", rest, flags=re.I) | |
| if COMPARATIVE.search(rest): | |
| return cid, other, why | |
| return None | |
| # two claims written as one range, and a signed figure under a direction word ("down -11.8%") | |
| TOKEN_RANGE = re.compile(r"\[\[([A-Z0-9_]+)\]\]\s*(–|—|-|\bto\b|\bthrough\b)\s*\[\[([A-Z0-9_]+)\]\]") | |
| SIGN_DIRECTION = re.compile(r"\b(down|up|fell|rose|declin\w*|increas\w*|decreas\w*|dropp?\w*|gain\w*|grew|shrank|" | |
| r"lower|higher)\s+(?:by\s+)?\[\[([A-Z0-9_]+)\]\]", re.I) | |
| SIGN_DIRECTION_AFTER = re.compile(r"\[\[([A-Z0-9_]+)\]\]\s+(slide|slump|decline|drop|fall|loss|gain|rise|increase|decrease|" | |
| r"jump|rally|retreat)\b", re.I) | |
| RECALL_TAG = re.compile(r"\bfrom memory\b", re.I) | |
| def token_structure_violation(sentence, catalog): | |
| """A reason when claim tokens are combined into a figure no claim states; None otherwise.""" | |
| for m in TOKEN_RANGE.finditer(sentence): | |
| a, b = m.group(1), m.group(3) | |
| if m.group(2).lower() in ("to", "through") and not (a.startswith("GUIDANCE_") and b.startswith("GUIDANCE_")): | |
| continue # "from [[A]] to [[B]]" is a comparison, checked elsewhere | |
| return f"[[{a}]] and [[{b}]] written as one range; each claim is a complete value" | |
| g = [m.group(1) for m in TOKEN.finditer(sentence) if m.group(1).startswith("GUIDANCE_")] | |
| seen = {} | |
| for cid in dict.fromkeys(g): | |
| r = getattr(catalog.get(cid), "rendered", None) | |
| if r is not None and r in seen: | |
| return (f"[[{seen[r]}]] and [[{cid}]] render identically ({r}); cite one and state its basis in words") | |
| seen[r] = cid | |
| for m in SIGN_DIRECTION.finditer(sentence): | |
| r = str(getattr(catalog.get(m.group(2)), "rendered", "") or "") | |
| if r[:1] in ("+", "-", "−"): | |
| return f"[[{m.group(2)}]] already carries its sign ({r}); '{m.group(1)}' doubles or inverts it" | |
| for m in SIGN_DIRECTION_AFTER.finditer(sentence): | |
| r = str(getattr(catalog.get(m.group(1)), "rendered", "") or "") | |
| if r[:1] in ("+", "-", "−"): | |
| return f"[[{m.group(1)}]] already carries its sign ({r}); '{m.group(2)}' doubles or inverts it" | |
| if RECALL_TAG.search(sentence): | |
| num = [c for c in TOKEN.findall(sentence) if getattr(catalog.get(c), "stype", "TEXT") != "TEXT"] | |
| if num: | |
| return f"a recalled fact ('from memory') is qualitative and may not carry the verified figure [[{num[0]}]]" | |
| return None | |
| # an ordering asserted between two claims must be true of their values ("6.0%–8.0%, far below 7.7%" is false) | |
| ORDER_UP = re.compile(r"\b(above|higher than|exceed\w*|more than|greater than|ahead of|over|outpac\w*|beyond)\b", re.I) | |
| ORDER_DOWN = re.compile(r"\b(below|lower than|less than|short of|under|lag\w*|behind|trail\w*)\b", re.I) | |
| def ordering_violation(sentence, range_fn): | |
| """(a, b, why) when a sentence orders two numeric claims ([[A]] … below … [[B]]) in a way their values contradict; | |
| a range that contains the other value is neither above nor below it.""" | |
| if range_fn is None: | |
| return None | |
| toks = list(TOKEN.finditer(sentence)) | |
| for x, y in zip(toks, toks[1:]): | |
| between = PERIOD_DESCRIPTOR.sub(" ", sentence[x.end():y.start()]) | |
| up, down = ORDER_UP.search(between), ORDER_DOWN.search(between) | |
| if bool(up) == bool(down): | |
| continue | |
| ra, rb = range_fn(x.group(1)), range_fn(y.group(1)) | |
| if not (ra and rb) or ra[2] != rb[2]: | |
| continue | |
| a_lo, a_hi, b_lo, b_hi = ra[0], ra[1], rb[0], rb[1] | |
| true = (a_lo > b_hi) if up else (a_hi < b_lo) | |
| if not true: | |
| word = (up or down).group(0) | |
| return x.group(1), y.group(1), [f"'{word}' is false for these values (the ranges overlap or the order is reversed)"] | |
| return None | |
| # ── structural relation rules (validator ids: RELATION_IDENTITY, RELATION_MAGNITUDE, RELATION_REQUIRED_FOR_PAIR, | |
| # MODEL_EVIDENCE_SCOPE, MODEL_EVIDENCE_VISIBILITY, GUIDANCE_EPISTEMIC_STATE, RETURN_DIRECTION_RENDER, | |
| # EXPECTATIONS_POLARITY). They read claim identities, never comparison words. | |
| def offered_violation(sentence, rules): | |
| """A token the author was not offered: a relation's magnitude without its pair, a signed quantity without its | |
| direction, or a hidden model's figure.""" | |
| if rules.offered is None: | |
| return None | |
| for m in TOKEN.finditer(sentence): | |
| cid = m.group(1) | |
| if cid == "OWN_RETURN" or cid in rules.offered: | |
| continue | |
| if cid in (rules.magnitude_bindings or {}) and cid not in ("PRICE_VS_INTRINSIC", "PRICE_VS_MECHANICAL", "PRICE_VS_COMPOSITE", | |
| "TARGET_VS_PRICE", "FORECAST_EPS_VS_CONSENSUS"): | |
| a, b = rules.magnitude_bindings[cid] | |
| return "RELATION_MAGNITUDE", f"[[{cid}]] is the magnitude of {a} vs {b} only; cite its relationship token" | |
| if cid in (rules.presentation_claims or ()): | |
| return "RELATION_IDENTITY", (f"[[{cid}]] is a relation's size and direction without its subject; cite the " | |
| "relationship token, which states both sides") | |
| if cid in (rules.phrase_of or {}): | |
| return "RETURN_DIRECTION_RENDER", f"[[{cid}]] is a signed figure; cite [[{rules.phrase_of[cid]}]], which carries its direction" | |
| if cid.endswith("__FULL_SENTENCE"): | |
| return "RELATION_CLAUSE_GRAMMAR", f"[[{cid}]] is a report-template form; authors write the relation as a clause" | |
| mid = rules.claim_model(cid) if rules.claim_model else None | |
| if mid: | |
| return "MODEL_EVIDENCE_VISIBILITY", f"[[{cid}]] belongs to {mid}, whose reliability does not allow it to be offered" | |
| return "RELATION_IDENTITY", f"[[{cid}]] was not offered to this author" | |
| return None | |
| def relation_violation(sentence, rules): | |
| """(validator, reason): a magnitude used for another pair, or two related quantities in one sentence without the | |
| approved relation that connects them (or with no relation possible at all).""" | |
| toks = list(dict.fromkeys(m.group(1) for m in TOKEN.finditer(sentence))) | |
| for mcid, pair in (rules.magnitude_bindings or {}).items(): | |
| if mcid not in toks: | |
| continue | |
| others = [t for t in toks if t != mcid] | |
| for i, a in enumerate(others): | |
| for b in others[i + 1:]: | |
| if frozenset({a, b}) != frozenset(pair) and rules.pair_fn and rules.pair_fn(a, b) is not None: | |
| return "RELATION_MAGNITUDE", (f"[[{mcid}]] measures {pair[0]} vs {pair[1]}; it cannot state the relation " | |
| f"between [[{a}]] and [[{b}]]") | |
| if rules.pair_fn is None: | |
| return None | |
| for i, a in enumerate(toks): | |
| for b in toks[i + 1:]: | |
| if a.startswith("GUIDANCE_") and b.startswith("GUIDANCE_"): | |
| continue # rows of one guidance table, as the company presents them | |
| st = rules.pair_fn(a, b) | |
| if st is None or st[0] == "OK": | |
| continue | |
| if st[0] == "RELATION": | |
| if st[1] not in toks: | |
| return "RELATION_REQUIRED_FOR_PAIR", (f"[[{a}]] and [[{b}]] share a sentence without their relationship " | |
| f"token [[{st[1]}]]") | |
| continue | |
| return "RELATION_REQUIRED_FOR_PAIR", (f"[[{a}]] and [[{b}]] cannot be related ({'; '.join(st[1])[:100]}); " | |
| "state them in separate sentences") | |
| return None | |
| # ── clause grammar around relationship tokens (function words only: where a clause may start and end) ── | |
| CLAUSE_CONJ = ("and", "but", "while", "whereas", "because", "since", "as", "so", "although", "though", "yet", "that", "or", | |
| "if", "when", "given", "today", "meanwhile", "however", "also", "thus", "hence", "now", "which", "where") | |
| MAX_FULL_PER_SENTENCE = 2 | |
| PREPOSITIONS = ("for", "of", "over", "in", "on", "at", "across", "through", "from", "to", "with", "within", "during") | |
| # a relative pronoun needs a gap in its clause ("which leaves …"); a complete clause after it has none ("which the price is …") | |
| CLAUSE_OPENERS = tuple(w for w in CLAUSE_CONJ if w != "which") | |
| # what may follow "[[FULL]], …": a conjunction, a relative clause, an appositive or a participial phrase — never a verb | |
| # whose subject would be the relation ("[[FULL]], destroy value") | |
| CLAUSE_CONTINUATIONS = CLAUSE_CONJ + ("who", "whose", "a", "an", "the", "leaving", "making", "meaning", "implying", "with") | |
| def clause_grammar_violation(sentence, full_clause): | |
| """A FULL relationship (or any clause-rendered claim) is a complete clause: it must open the sentence or follow a | |
| clause boundary (never a relative pronoun, which needs a gap the clause does not have), and be followed by the end | |
| of the sentence, a boundary, or a comma plus a continuation that is not a verb whose subject would be the relation | |
| ("[[FULL]], destroy value"); at most two per sentence.""" | |
| if full_clause is None: | |
| return None | |
| toks = [m for m in TOKEN.finditer(sentence) if full_clause(m.group(1))] | |
| if len(toks) > MAX_FULL_PER_SENTENCE: | |
| return f"{len(toks)} complete relationship clauses in one sentence (at most {MAX_FULL_PER_SENTENCE}); split it" | |
| for m in toks: | |
| before = sentence[:m.start()].rstrip() | |
| after = sentence[m.end():].lstrip() | |
| last = (re.findall(r"[A-Za-z']+", before) or [""])[-1].lower() | |
| ok_b = not before or before[-1] in ",;:(—–-\"'" or last in CLAUSE_OPENERS | |
| if not after or after[0] in ".;:)!?—–-\"'": | |
| ok_a = True | |
| elif after[0] == ",": | |
| nxt = (re.findall(r"[A-Za-z'’]+", after[1:30]) or [""])[0].lower() | |
| ok_a = not nxt or nxt in CLAUSE_CONTINUATIONS or nxt.endswith(("ing", "ed", "ly")) | |
| else: | |
| ok_a = (re.findall(r"[A-Za-z']+", after[:20]) or [""])[0].lower() in CLAUSE_CONJ | |
| if not ok_b: | |
| return (f"[[{m.group(1)}]] is a complete clause and cannot be the object of '{before.split()[-1]}'; use its " | |
| "__CLAUSE or __COMPACT form after the subject, or start a new clause") | |
| if not ok_a: | |
| return (f"[[{m.group(1)}]] is a complete clause and cannot be the subject of " | |
| f"'{after.lstrip(', ').split()[0]}'; end the clause after it") | |
| return None | |
| def operand_doubled(sentence, catalog): | |
| """A relation renders its own operands ("the price ($403.30) is 9.1% above …"): the same operand cited again as its | |
| own token in that sentence states the figure twice ("Buybacks at $403.30, the price ($403.30) is …"). The operands are | |
| the relation's typed lhs / rhs claims; a mode that does not render an operand does not own it.""" | |
| toks = [m.group(1) for m in TOKEN.finditer(sentence)] | |
| for rid in toks: | |
| rc = catalog.get(rid) | |
| if getattr(rc, "stype", None) != "REL" or not isinstance(getattr(rc, "value", None), dict): | |
| continue | |
| rendered = str(rc.rendered or "") | |
| for side in ("lhs_claim_id", "rhs_claim_id"): | |
| op = rc.value.get(side) | |
| oc = catalog.get(op) if op else None | |
| if op in toks and op != rid and oc is not None and oc.rendered and str(oc.rendered) in rendered: | |
| return (f"CLAIM_QUALIFIER_DOUBLED: [[{op}]] ({oc.rendered}) is already stated inside [[{rid}]]; cite the " | |
| "relation alone") | |
| return None | |
| def mode_subject_violation(sentence, rel_subjects): | |
| """A CLAUSE / COMPACT relation token is a predicate about its lhs: the words of its own clause before it must name | |
| the lhs and must not name the rhs (the relation can never be attached to the opposite subject).""" | |
| for m in TOKEN.finditer(sentence): | |
| cid = m.group(1) | |
| if "__" not in cid or cid.endswith("__FULL_SENTENCE"): | |
| continue | |
| base = cid.split("__")[0] | |
| lhs, rhs = (rel_subjects or {}).get(base, ((), ())) | |
| # coordinated predicates share the subject: "the price [[X__PREDICATE]] and [[Y__PREDICATE]]" | |
| if re.search(r"\[\[[A-Z0-9_]+__(?:PREDICATE|CLAUSE|COMPACT)\]\]\s*,?\s*(?:and|but|yet|while)\s*$", sentence[:m.start()]): | |
| prev_base = re.findall(r"\[\[([A-Z0-9_]+)__", sentence[:m.start()])[-1] | |
| if (rel_subjects or {}).get(prev_base, ((), ()))[0] == lhs: | |
| continue | |
| clause = re.split(r"[,;:(—–]|\b(?:and|but|while|whereas|because|since|so)\b", TOKEN.sub(lambda t: " " + t.group(1).lower() | |
| .replace("_", " ") + " ", sentence[:m.start()]))[-1].lower() | |
| if rhs and any(w.lower() in clause for w in rhs): | |
| return f"[[{cid}]] describes {base.split('_', 1)[1].lower().replace('_', ' ')}'s left-hand side; its subject here is the right-hand side" | |
| if lhs and not any(w.lower() in clause for w in lhs): | |
| return f"[[{cid}]] must follow the subject it describes ({', '.join(lhs[:3])})" | |
| return None | |
| def trend_violation(sentence, rules): | |
| """A trend conclusion (accelerating, decelerating, improving, deteriorating, expanding, contracting) about a financial | |
| metric needs an approved TrendRelationship in the sentence, or a typed change claim that carries it.""" | |
| import relations as _rel | |
| found = _rel.trend_asserted(TOKEN.sub(" ", sentence)) | |
| if not found: | |
| return None | |
| toks = {m.group(1) for m in TOKEN.finditer(sentence)} | |
| cited = {rules.trends_allowed[t] for t in toks if t in (rules.trends_allowed or {})} | |
| has_change = bool(toks & set(rules.change_claims or ())) | |
| for cls, word in found: | |
| if cls in cited: | |
| continue | |
| if cls in (_rel.IMPROVING, _rel.DETERIORATING, _rel.EXPANDING, _rel.CONTRACTING) and has_change: | |
| continue | |
| return (f"'{word}' asserts a {cls.lower()} trend without an approved trend relationship (no compatible earlier " | |
| "comparator on the same perimeter)") | |
| return None | |
| def interpretation_violation(sentence, interp): | |
| """Evidence a family may only DESCRIBE (e.g. 13F when it is not a return signal) used to carry an interpretation.""" | |
| import evidence as _ev | |
| low = TOKEN.sub(lambda t: " " + t.group(1).lower() + " ", sentence).lower() | |
| toks = {m.group(1) for m in TOKEN.finditer(sentence)} | |
| if re.search(r"\b(?:not|no|never)\b[^.]{0,30}\bsignal", low): | |
| return None # "not a signal" states the permission itself | |
| for fam, d in (interp or {}).items(): | |
| if d.get("permission") != _ev.DESCRIPTIVE_ONLY: | |
| continue | |
| refers = bool(toks & set(d.get("claims") or ())) or any(w in low for w in d.get("subjects") or ()) | |
| if not refers: | |
| continue | |
| hit = next((w for w in _ev.INTERPRETIVE_SURFACE if w in low), None) | |
| # "<evidence>, aligning / signalling / suggesting …" or "which shows …": a consequence drawn from the evidence | |
| cons = re.search(r",\s*(?:\w+ly\s+)?(?!(?:including|excluding|according|following|totaling|totalling|representing|" | |
| r"holding|reporting|opening|adding|trimming|exiting)\b)(\w+ing)\b(?!\s+(?:to|in|from|over|across|" | |
| r"(?:the\s+|a\s+)?[a-z0-9_]*_[a-z0-9_]+\b))|" | |
| r"\bwhich\s+(?:shows?|signals?|suggests?|indicates?|means?|implies|reflects?)\b", low) | |
| if cons and not hit and any(re.search(r"\b" + re.escape(c.lower()) + r"\b", low) for c in d.get("claims") or ()): | |
| return (f"{fam} evidence is DESCRIPTIVE_ONLY here; '{cons.group(0).strip(', ')}' draws a consequence from it " | |
| "(state the positions or trades only)") | |
| if hit: | |
| return f"{fam} evidence is DESCRIPTIVE_ONLY here; '{hit.strip()}' interprets it (state the positions or counts only)" | |
| return None | |
| def guidance_binding_violation(sentence, names): | |
| """A GUIDANCE_n value attached to the wrong subject: its own clause (the text since the previous claim token, and up | |
| to the next one) must name its concept (a share-repurchase value is never a "private-markets base").""" | |
| toks = list(TOKEN.finditer(sentence)) | |
| for i, m in enumerate(toks): | |
| cid = m.group(1) | |
| if cid not in (names or {}): | |
| continue | |
| concept, words = names[cid][0], names[cid][1] | |
| basis_words = names[cid][2] if len(names[cid]) > 2 else () | |
| lo = toks[i - 1].end() if i > 0 else 0 | |
| hi = toks[i + 1].start() if i + 1 < len(toks) else len(sentence) | |
| # its own clause: back to the previous clause boundary or claim, forward to the next boundary or claim | |
| before = re.split(r"[,;:(—–]", sentence[lo:m.start()])[-1] | |
| after = re.split(r"[,;:)—–.]", sentence[m.end():hi])[0] | |
| window = (before[-90:] + " " + after[:45]).lower() | |
| if not any(w in window for w in words): | |
| return (f"[[{cid}]] is the {concept.lower().replace('_', ' ')} guidance value; its clause must name it " | |
| f"({', '.join(words[:3])})") | |
| if basis_words and not any(w in window for w in basis_words): | |
| return (f"[[{cid}]] is one of several {concept.lower().replace('_', ' ')} rows with different values; its clause " | |
| f"must name its basis ({', '.join(basis_words)})") | |
| return None | |
| def relation_logic_violation(sentence, cases, variables=None): | |
| """An ALTERNATIVE_CONDITIONALS proposition restated as a joint one: a claim from each case in one sentence, or a | |
| paraphrase that says the price requires both case variables. Only [[REL_EXPECTATIONS_STORY]] states both cases, and it | |
| renders them as alternatives. The check reads the relation's own cases and variables, not the word "and".""" | |
| import relations as _rel | |
| if not cases: | |
| return None | |
| toks = {m.group(1).split("__")[0] for m in TOKEN.finditer(sentence)} | |
| if "REL_EXPECTATIONS_STORY" in toks: | |
| return None | |
| hit = [c for c, ids in cases.items() if toks & set(ids)] | |
| if len(hit) > 1: | |
| return ("the break-even revenue case and margin case are ALTERNATIVE conditional solves (each holds the other " | |
| "variable constant); stating both in one sentence reads as a joint requirement — cite " | |
| "[[REL_EXPECTATIONS_STORY]] or use separate sentences") | |
| # the requirement and both case variables must share one clause ("…high margins and revenue growth, but the stock | |
| # already prices in strong economics" describes the business, then states a requirement naming neither) | |
| for seg in re.split(r"[;:—–]|,?\s*\b(?:but|while|whereas|although|though|yet)\b", sentence): | |
| low = TOKEN.sub(" ", seg).lower() | |
| stoks = {m.group(1).split("__")[0] for m in TOKEN.finditer(seg)} | |
| if variables and any(p in low for p in _rel.REQUIREMENT_PREDICATE) and any(w in low for w in _rel.REQUIREMENT_SUBJECT) \ | |
| and not any(mk in low for mk in _rel.ALTERNATIVE_MARKERS): | |
| named = [c for c, words in variables.items() | |
| if any(re.search(r"\b" + re.escape(w), low) for w in words) or (stoks & set(cases.get(c) or ()))] | |
| if len(named) > 1: | |
| return ("a paraphrase of what the price requires names both alternative cases (revenue and margin) as one " | |
| "requirement; only [[REL_EXPECTATIONS_STORY]] may state both") | |
| return None | |
| LINKING = ("is", "are", "was", "were", "be", "been", "being", "sits", "sit", "sitting", "stands", "stand", "trades", "trade", | |
| "trading", "remains", "remain", "lies", "runs", "looks", "look", "comes", "come", "lands", "land", "stays", "stay", | |
| "still", "now", "already", "roughly", "about", "nearly", "currently", "at") | |
| def relation_slot_violation(sentence, rel_subjects, catalog=None): | |
| """Typed render slots: CLAUSE / COMPACT follow a linking verb; PREDICATE carries its own verb and follows its subject | |
| directly; no mode may be followed by its right-hand side again ("7.8% above consensus than sell-side consensus").""" | |
| for m in TOKEN.finditer(sentence): | |
| cid = m.group(1) | |
| if "__" not in cid: | |
| continue | |
| base, mode = cid.split("__", 1) | |
| if mode == "FULL_SENTENCE": | |
| if TOKEN.sub("", sentence).strip(" .!?;:—–-\"'"): | |
| return f"[[{cid}]] is a whole sentence; write nothing else in that sentence" | |
| continue | |
| prev = (re.findall(r"[A-Za-z'’]+", sentence[:m.start()]) or [""])[-1].lower() | |
| if mode in ("CLAUSE", "COMPACT") and prev not in LINKING: | |
| return f"[[{cid}]] needs a verb before it (\"the price is …\"); use the __PREDICATE form directly after the subject" | |
| _lhs, rhs = (rel_subjects or {}).get(base, ((), ())) | |
| if mode == "PREDICATE": | |
| # it carries its own verb: it must follow the subject noun itself ("the price [[…__PREDICATE]]") | |
| tail = sentence[:m.start()].rstrip().lower() | |
| def _subject_then_modifier(t): | |
| # the subject noun, then at most a short prepositional modifier with no verb ("the engine's forecast for | |
| # the next twelve months [[…__PREDICATE]]"); earlier tokens are masked so their ids never read as words | |
| t = re.sub(r"\[\[[a-z0-9_]+\]\]", " ⟨tok⟩ ", t) | |
| for w in (_lhs or ()): | |
| i = t.rfind(w.lower()) | |
| if i < 0: | |
| continue | |
| rest = t[i + len(w):] | |
| words = re.findall(r"[a-z'’]+", rest) | |
| if not rest.strip() or (len(words) <= 7 and words and (words[0] in PREPOSITIONS or | |
| (len(words) > 1 and words[1] in PREPOSITIONS)) | |
| and not any(x in LINKING for x in words) and not re.search(r"[,;:]", rest)): | |
| return True | |
| # coordinated predicates: "the price [[X__PREDICATE]] yet [[Y__PREDICATE]]" share one subject | |
| if re.fullmatch(r"\s*⟨tok⟩\s*,?\s*(?:and|but|yet|while)\s*", rest): | |
| return True | |
| return False | |
| # the predicate carries a singular verb ("is 9.0% below …"): a plural subject breaks agreement ("the shares is") | |
| if prev in ("shares", "stocks", "they", "multiples", "valuations") and \ | |
| re.match(r"\s*(?:is|sits|stands|trades|remains)\b", str(getattr((catalog or {}).get(cid), "rendered", "") or "")): | |
| return f"[[{cid}]] carries a singular verb; its subject '{prev}' is plural (write \"the stock\" or \"the price\")" | |
| if prev in LINKING or not _subject_then_modifier(tail): | |
| return f"[[{cid}]] carries its own verb and must directly follow its subject ({', '.join((_lhs or ('the subject',))[:3])})" | |
| after = re.split(r"[,;:.(—–]|\b(?:and|but|while|which|because|so)\b", sentence[m.end():])[0].lower() | |
| if rhs and any(w.lower() in after for w in rhs): | |
| return f"[[{cid}]] already names its right-hand side; do not repeat it after the token" | |
| return None | |
| PRICE_TERMS = ("price", "shares", "stock") | |
| def valuation_relation_violation(sentence, vrels): | |
| """Direction language about a valuation anchor (story value, mechanical DCF, market composite) must state its subject | |
| and object and agree with the anchor's typed relation: "the price is above the DCF" and "the DCF sits below the | |
| price" both hold; "the mechanical DCF discount" names no side and is rejected.""" | |
| import relations as _rel | |
| if not vrels: | |
| return None | |
| toks = {m.group(1).split("__")[0] for m in TOKEN.finditer(sentence)} | |
| full = TOKEN.sub(lambda t: " " + t.group(1).lower() + " ", sentence).lower() | |
| price_named = any(re.search(r"\b" + p + r"\b", full) for p in PRICE_TERMS) | |
| flip = {_rel.ABOVE: _rel.BELOW, _rel.BELOW: _rel.ABOVE} | |
| all_anchors = [a for rid in _rel.VALUATION_ANCHORS for a in _rel.VALUATION_ANCHORS[rid]] | |
| # a direction word binds within its own clause, to the nearest anchor ("below market pricing while it sits above | |
| # story value" states two relations, one per clause) | |
| for low in re.split(r"[;:,(—–]|\b(?:while|but|whereas|and|although|though|yet|even as)\b", full): | |
| for rid, anchors in _rel.VALUATION_ANCHORS.items(): | |
| if rid not in vrels: | |
| continue | |
| for a in anchors: | |
| for am in re.finditer(r"\b" + re.escape(a) + r"\b", low): | |
| for rtype, words in _rel.VALUATION_DIRECTION.items(): | |
| for w in words: | |
| for dm in re.finditer(r"\b" + w + r"\b", low): | |
| if abs(dm.start() - am.start()) > 45: | |
| continue | |
| lo_, hi_ = sorted((dm.end(), am.start())) if dm.start() < am.start() else sorted((am.end(), dm.start())) | |
| if any(re.search(r"\b" + re.escape(o) + r"\b", low[lo_:hi_]) for o in all_anchors if o != a): | |
| continue # another anchor sits between them: the word is not about this one | |
| # "story value premium" next to its relation token: a premium OVER the anchor (a bare | |
| # compound names no side and stays unexplicit) | |
| compound = w in ("premium", "discount") and 0 <= dm.start() - am.end() <= 3 | |
| if compound: | |
| explicit, said = rid in toks, rtype | |
| elif dm.start() < am.start(): # "<price> … premium/above … <anchor>": price is the subject | |
| before = low[max(0, dm.start() - 80):dm.start()] | |
| # "Shares trade below X but above Y": a clause with no subject of its own shares the | |
| # sentence's subject (the price named in the first clause) | |
| bare = not re.sub(r"\b(?:is|are|sits?|stands?|trades?|trading|still|now|also|well|" | |
| r"only|slightly|modestly|far|much|at|a)\b|[^a-z]", "", before) | |
| explicit = any(p in before for p in PRICE_TERMS) or \ | |
| (price_named and (re.search(r"\b(?:it|they)\b", before) or bare)) | |
| said = rtype | |
| if not compound and dm.start() >= am.start() and w in ("cheap", "expensive", "rich", | |
| "undervalued", "overvalued"): | |
| # an evaluative adjective describes the stock ("the composite argues cheap") | |
| explicit, said = (price_named or rid in toks), rtype | |
| elif not compound and dm.start() >= am.start(): # "<anchor> … below … <price>": anchor is subject | |
| between = low[am.end():dm.start()] | |
| if any(re.search(r"\b" + p + r"\b", between) for p in PRICE_TERMS): | |
| explicit, said = True, rtype # "composite-vs-price discount" | |
| else: | |
| after_ = low[dm.end():dm.end() + 40] | |
| explicit = any(p in after_ for p in PRICE_TERMS) or \ | |
| (price_named and re.match(r"\W*(?:the\s+)?(?:it|them)\b", after_)) | |
| said = flip[rtype] | |
| if not explicit and rid not in toks: | |
| return (f"'{w}' about the {a} names no subject and object (whose {w}?); state it as the " | |
| f"price relative to the {a}, or cite [[{rid}]]") | |
| if said != vrels[rid]: | |
| return f"'{w}' contradicts [[{rid}]]: the price is {vrels[rid].lower()} the {a}" | |
| return None | |
| UNIVERSAL_ANCHORS = r"\b(?:on|by|across|against|versus|vs\.?)\s+(?:every|all|each|any)\s+(?:\w+\s+)?(?:anchors?|measures?|metrics?|" \ | |
| r"bases|basis|yardsticks?|methods?|valuation (?:anchors?|measures?|methods?))\b|\bon all (?:counts|fronts)\b" | |
| def universal_valuation_violation(sentence, vrels): | |
| """"Expensive on every anchor": a direction claimed for ALL valuation anchors must hold for each anchor's relation.""" | |
| import relations as _rel | |
| low = TOKEN.sub(" ", sentence).lower() | |
| if not vrels or not re.search(UNIVERSAL_ANCHORS, low): | |
| return None | |
| for rtype, words in _rel.VALUATION_DIRECTION.items(): | |
| if any(re.search(r"\b" + w + r"\b", low) for w in words): | |
| against = [rid for rid, t in vrels.items() if t in (_rel.ABOVE, _rel.BELOW) and t != rtype] | |
| if against: | |
| return (f"claims the price is {rtype.lower()} value on every anchor, but [[{against[0]}]] holds " | |
| f"{vrels[against[0]].lower()}") | |
| return None | |
| def evidence_domain_violation(sentence, rules): | |
| """Evidence from one domain (e.g. MOMENTUM: price vs the 200-day average) used to establish a conclusion in another | |
| (a FUNDAMENTALS trend such as revenue deceleration) without evidence from that domain in the sentence.""" | |
| import evidence as _ev | |
| import relations as _rel | |
| toks = {m.group(1) for m in TOKEN.finditer(sentence)} | |
| low = TOKEN.sub(" ", sentence).lower() | |
| concl = [] | |
| for stems in _rel.TREND_SURFACE.values(): | |
| for st in stems: | |
| for cm in re.finditer(r"\b" + re.escape(st), low): | |
| concl.append((st, _ev.conclusion_domain(low[max(0, cm.start() - 80):cm.end() + 60]))) | |
| if not concl: | |
| return None | |
| if any(t.startswith(_ev.FUNDAMENTAL_EVIDENCE_PREFIXES) or t in (rules.trends_allowed or {}) for t in toks): | |
| return None # fundamentals / guidance evidence (or a trend relation) is present | |
| for dom, d in _ev.EVIDENCE_DOMAINS.items(): | |
| if not (toks & set(d["claims"]) or any(w in low for w in d["subjects"])): | |
| continue | |
| for word, cdom in concl: | |
| if cdom not in d["allowed_target_domains"]: | |
| return (f"{dom.lower()} evidence cannot establish a {cdom.lower().replace('_', ' ')} conclusion ('{word}'); " | |
| f"it may support only {', '.join(x.lower() for x in d['allowed_target_domains'])}") | |
| return None | |
| TYPED_SEGMENT_COUNT = re.compile(r"\b(\d+|two|three|four|five|six|seven|eight|nine|ten)\s+(?:reportable\s+|reported\s+|" | |
| r"operating\s+|core\s+|business\s+)?(?:segments|divisions|reporting units)\b", re.I) | |
| def segment_perimeter_violation(sentence, sp): | |
| """A segment count typed as text, or the filed (pre-separation) count or coverage cited without saying it describes | |
| the business as filed: a continuing-company sentence may not borrow the historical count.""" | |
| low = TOKEN.sub(" ", sentence).lower() | |
| if TYPED_SEGMENT_COUNT.search(low): | |
| return "a segment count must be cited as [[SEGMENT_COUNT_FILED]] or [[SEGMENT_COUNT_CONTINUING]], never typed" | |
| if not sp: | |
| return None | |
| toks = {m.group(1) for m in TOKEN.finditer(sentence)} | |
| for cid in toks & set(sp.get("historical_claims") or ()): | |
| if not any(q in low for q in sp.get("qualifiers") or ()): | |
| return (f"[[{cid}]] describes the business as filed, before the separation; say so (e.g. 'before the separation', " | |
| "'as filed') or use the continuing-company count") | |
| return None | |
| def evidence_conclusion_violation(sentence, uses): | |
| """(validator, reason): evidence used for a conclusion type its EvidenceUse does not allow (beta → an entry point, | |
| insider purchases → market pressure, a third-party rating headline → the issuer's moat). A conclusion stated only as | |
| a denial ("not a catalyst") is descriptive.""" | |
| import evidence as _ev | |
| if not uses: | |
| return None | |
| toks = {m.group(1).split("__")[0] for m in TOKEN.finditer(sentence)} | |
| low = TOKEN.sub(" ", sentence).lower() | |
| present = [k for k, u in uses.items() if toks & set(u.get("evidence_claim_ids") or ()) | |
| or any(w in low for w in u.get("subjects") or ())] | |
| if not present: | |
| return None | |
| other = any(t.startswith(_ev.FUNDAMENTAL_EVIDENCE_PREFIXES) or t.startswith("REL_") | |
| or t in ("PRICE", "INTRINSIC_VALUE", "MARKET_COMPOSITE", "ANALYST_TARGET", "MECHANICAL_VALUE") for t in toks) | |
| for ctype, surface in _ev.CONCLUSION_TYPES.items(): | |
| for w in surface: | |
| for m in re.finditer(re.escape(w), low): | |
| if DENIAL.search(low[max(0, m.start() - 28):m.start()]): | |
| continue | |
| if other and ctype in (_ev.MOAT_OR_SECULAR_GROWTH, _ev.STOCK_UPSIDE): | |
| continue # fundamentals / valuation evidence in the sentence may support it | |
| if any(ctype in (uses[k].get("allowed_conclusion_types") or ()) for k in present): | |
| continue | |
| vid = "NEWS_EVIDENCE_SCOPE" if present == ["NEWS"] or ("NEWS" in present and len(present) == 1) else \ | |
| "EVIDENCE_CONCLUSION_SCOPE" | |
| return vid, (f"{', '.join(p.lower() for p in present)} evidence may support only " | |
| f"{', '.join(sorted({c.lower() for k in present for c in uses[k]['allowed_conclusion_types']}))}; " | |
| f"'{w.strip()}' asserts a {ctype.lower().replace('_', ' ')} conclusion") | |
| return None | |
| def performance_violation(sentence, returns, issuer_words=()): | |
| """A statement about how the stock performed ("a weak year for the stock") without the signed return claim it | |
| describes, or against its sign.""" | |
| import relations as _rel | |
| found = _rel.performance_asserted(TOKEN.sub(" ", sentence), issuer_words) | |
| if not found: | |
| return None | |
| cited = {m.group(1): (returns or {}).get(m.group(1)) for m in TOKEN.finditer(sentence) if m.group(1) in (returns or {})} | |
| for sign, word, year in found: | |
| ok = [c for c, v in cited.items() if v is not None and ((v < 0) == (sign == _rel.NEGATIVE_PERF)) | |
| and (not year or _rel.STOCK_RETURN_CLAIMS.get(c) == "12M")] | |
| if not ok: | |
| want = "[[RETURN_12M_PHRASE]]" if year else "[[RETURN_12M_PHRASE]] or [[RETURN_3M_PHRASE]]" | |
| if cited: | |
| return f"'{word}' describes a {sign.lower()} stock move, but the cited return has the other sign or period" | |
| return f"'{word}' describes how the stock performed without the return it describes ({want})" | |
| return None | |
| def assessment_violation(sentence, assessments, assumption_claims): | |
| """'Conservative' / 'aggressive' about a story assumption without, or against, its typed AssumptionAssessment; or | |
| about the assumptions collectively, which have no assessment.""" | |
| import relations as _rel | |
| low_full = sentence.lower() | |
| # "high" / "low" about an assumption, in its clause or the relative clause right after it ("…of [[X]], which is high") | |
| for m in TOKEN.finditer(sentence): | |
| cid = m.group(1) | |
| if cid not in (assumption_claims or ()): | |
| continue | |
| before = re.split(r"[,;:—–(]|\b(?:and|but|while|whereas|plus)\b", sentence[:m.start()])[-1].lower() | |
| rel = re.match(r"\s*(?:,?\s*(?:which|that)\s+)?(?:is|looks|seems|appears|remains)\s+([^,;.]{0,40})", sentence[m.end():]) | |
| scope = before[-40:] + " " + (rel.group(1).lower() if rel else "") | |
| for level, words in _rel.LEVEL_WORDS.items(): | |
| w = next((x for x in words if re.search(r"(?<![\w-])" + x + r"(?![\w-])", scope)), None) | |
| if not w or re.search(r"\b(?:not|hardly|neither)\W+(?:\w+\W+)?" + w, scope): | |
| continue | |
| claimed = _rel.level_assessment(cid, level) | |
| a = (assessments or {}).get(cid) or {} | |
| if a.get("assessment") != claimed: | |
| return (f"'{w}' makes [[{cid}]] {claimed.lower()}, but it is assessed " | |
| f"{str(a.get('assessment') or 'UNASSESSED').lower().replace('_', ' ')}" | |
| f"{' (' + a['basis'] + ')' if a.get('basis') else ''}") | |
| for clause in re.split(r"[,;:—–(]|\b(?:and|but|while|whereas|plus)\b", sentence): | |
| low = clause.lower() | |
| for cls, words in _rel.ASSESSMENT_WORDS.items(): | |
| m = next((re.search(r"\b" + w + r"\w*", low) for w in words if re.search(r"\b" + w + r"\w*", low)), None) | |
| if not m or re.search(r"\b(?:not|hardly|far from|anything but|neither|nor)\W+(?:\w+\W+)?$", low[:m.start()]): | |
| continue | |
| toks = [t.group(1) for t in TOKEN.finditer(clause) if t.group(1) in (assumption_claims or ())] | |
| for t in toks: | |
| a = (assessments or {}).get(t) or {} | |
| if a.get("assessment") != cls: | |
| return (f"[[{t}]] is assessed {str(a.get('assessment') or 'UNASSESSED').lower().replace('_', ' ')}" | |
| f"{' (' + a['basis'] + ')' if a.get('basis') else ''}; '{m.group(0)}' asserts {cls.lower()}") | |
| if not toks: | |
| # "…a failure probability of [[STORY_FAILURE]] …, suggesting downside is framed conservatively": the word | |
| # judges the assumptions the sentence cites, wherever they sit | |
| for t in [x.group(1) for x in TOKEN.finditer(sentence) if x.group(1) in (assumption_claims or ())]: | |
| a = (assessments or {}).get(t) or {} | |
| if a.get("assessment") != cls: | |
| return (f"'{m.group(0)}' judges [[{t}]], which is assessed " | |
| f"{str(a.get('assessment') or 'UNASSESSED').lower().replace('_', ' ')}") | |
| if not toks and re.search(r"\b" + re.escape(m.group(0)) + r"\W+(?:\w+\W+){0,2}(?:assumptions?|inputs?|parameters?|" | |
| r"discounting|valuation inputs?)\b", low_full): | |
| return (f"'{m.group(0)}' judges the story's assumptions collectively; only a single assumption with a typed " | |
| "assessment may be called conservative or aggressive") | |
| return None | |
| _QUOTE = re.compile(r"[\"“]([^\"”]{12,300})[\"”]|(?<![A-Za-z])['‘]([^'’]{12,300}?)['’](?![A-Za-z])") | |
| _COMPANY_SIDE = r"\b(?:management|the company|company|executives?|ceo|cfo|coo|chief executive|chief financial|leadership|guid\w*)\b" | |
| _ADMISSION = r"\b(?:acknowledg\w*|admit\w*|admission|conced\w*|concession|confess\w*|conceding)\b" | |
| _ANALYST_SIDE = r"\b(?:analysts?|questioner|a question|the question|asked)\b" | |
| def quote_violation(sentence, turns, company_words=()): | |
| """A call quotation attributed to the wrong side (an analyst's question presented as what the company acknowledges, | |
| or management's words as an analyst's), or presented as from the call without being in it.""" | |
| if turns is None: | |
| return None | |
| import evidence as _ev | |
| for m in _QUOTE.finditer(sentence): | |
| q = m.group(1) or m.group(2) | |
| if len(re.findall(r"[A-Za-z]+", q)) < 4: | |
| continue # a scare-quoted word or two is not a quotation of the call | |
| turn = _ev.quote_speaker(turns, q) | |
| before = sentence[:m.start()].lower() | |
| framed_call = re.search(r"\b(?:call|transcript|said|says|noted|stated|per|remarked|told)\b", sentence.lower()) | |
| if turn is None: | |
| if framed_call: | |
| return f"the quotation '{q[:50]}' is not in the call transcript the analysis used" | |
| continue | |
| company = bool(re.search(_COMPANY_SIDE, before) or re.search(_ADMISSION, before) | |
| or any(w and w in before for w in company_words)) | |
| if turn["role"] == _ev.ANALYST and company: | |
| return (f"'{q[:50]}' was said by an analyst ({turn['speaker']}) asking a question; it cannot be presented as " | |
| "what the company or management acknowledges") | |
| if turn["role"] == _ev.MANAGEMENT and re.search(_ANALYST_SIDE, before) and not company: | |
| return f"'{q[:50]}' was said by management ({turn['speaker']}), not by an analyst" | |
| return None | |
| _SPEECH = r"\b(?:acknowledg\w*|admit\w*|conced\w*|said|says|noted|notes|stated|states|told|highlighted|highlights|" \ | |
| r"remarked|conceded|warned|flagged|signal(?:l)?ed|touted|touts|emphasi[sz]ed|cites|cited|claims|claimed|" \ | |
| r"describes|described|points to|pointed to|argues|argued|reiterat\w*|boasts|boasted|promised|promises|insists|insisted)\b" | |
| def paraphrase_attribution_violation(sentence, turns, company_words=()): | |
| """A statement attributed to management ("the CEO acknowledged on the call that monetization remains challenging") | |
| whose content comes from an analyst's turn and from no management turn.""" | |
| if not turns: | |
| return None | |
| low = TOKEN.sub(" ", sentence).lower() | |
| sp = re.search(_SPEECH, low) | |
| if not sp or not (re.search(_COMPANY_SIDE, low[:sp.start()]) or any(w and w in low[:sp.start()] for w in company_words)): | |
| return None | |
| import evidence as _ev | |
| # speech has lineage like a number: what management said is evidenced by its own words, quoted from a management | |
| # turn. A paraphrase can reverse it ("acknowledged monetization remains challenging" for "how we're monetizing and | |
| # growing … very constructive"), so an unquoted attribution is not publishable | |
| quotes = [q.group(1) or q.group(2) for q in _QUOTE.finditer(sentence)] + \ | |
| [q.group(1) for q in re.finditer(r"['‘]([^'’]{6,120})['’]", sentence)] | |
| quotes = [q for q in quotes if len(re.findall(r"[A-Za-z]+", q)) >= 3] | |
| if not quotes: | |
| return ("attributes a statement to management without quoting its words from the call; quote the speaker " | |
| "(at least three words) or describe the company's filings instead") | |
| if not any((_ev.quote_speaker(turns, q) or {}).get("role") == _ev.MANAGEMENT for q in quotes): | |
| return "the quoted words are not management's words in the call transcript" | |
| return None | |
| STATEMENT_NOUNS = {"balance sheet": "BALANCE_SHEET", "income statement": "INCOME_STATEMENT", "p&l": "INCOME_STATEMENT", | |
| "profit and loss": "INCOME_STATEMENT", "cash flow statement": "CASH_FLOW", | |
| "cash-flow statement": "CASH_FLOW", "statement of cash flows": "CASH_FLOW"} | |
| def statement_domain_violation(sentence, statements): | |
| """A figure placed on the wrong financial statement ("the balance sheet carries amortization of $785M"): its own | |
| clause may name only the statement its concept belongs to.""" | |
| for clause in re.split(r"[,;:—–(.]", sentence): | |
| low = clause.lower() | |
| named = {st for noun, st in STATEMENT_NOUNS.items() if noun in low} | |
| if not named: | |
| continue | |
| for t in TOKEN.finditer(clause): | |
| own = (statements or {}).get(t.group(1)) | |
| if own and own not in named: | |
| return (f"[[{t.group(1)}]] is an {own.lower().replace('_', ' ')} item; its clause places it on the " | |
| f"{' / '.join(sorted(n.lower().replace('_', ' ') for n in named))}") | |
| return None | |
| def family_comparison_violation(sentence): | |
| """A comparison between two metric families ("earnings compound faster than revenue") with no typed relation.""" | |
| import relations as _rel | |
| for a, b, w in _rel.family_comparison_asserted(TOKEN.sub(" ", sentence)): | |
| return (f"compares {a.lower().replace('_', ' ')} with {b.lower().replace('_', ' ')} ('{w}') but no typed relation " | |
| "connects the two (different metrics, and no compatible growth pair on one perimeter)") | |
| return None | |
| def proximity_violation(sentence, magnitudes): | |
| """'Close to fair value' / 'fairly valued' without a cited valuation anchor inside the closeness band.""" | |
| import relations as _rel | |
| low = TOKEN.sub(" ", sentence).lower() | |
| m = re.search(_rel.PROXIMITY_SURFACE, low) | |
| if not m or re.search(r"\b(?:not|far from|no longer|hardly)\W+(?:\w+\W+){0,2}$", low[:m.start()]): | |
| return None | |
| cited = {t.group(1).split("__")[0] for t in TOKEN.finditer(sentence)} & set(magnitudes or {}) | |
| if not cited: | |
| return f"'{m.group(0)}' is a valuation judgement without a valuation anchor's relationship token" | |
| far = [c for c in cited if magnitudes[c] is not None and abs(magnitudes[c]) > _rel.CLOSENESS_BAND] | |
| if far: | |
| return (f"'{m.group(0)}' but the cited anchor gap is {format_percent(abs(magnitudes[far[0]]), 1)}, outside the " | |
| f"{format_percent(_rel.CLOSENESS_BAND, 0)} closeness band") | |
| return None | |
| RETURN_SUBJECT = r"\b(?:expected return|probability[- ]weighted(?: return)?|weighted return|scenario[- ]weighted|overall return|" \ | |
| r"(?:my|our|the panel'?s?|the pm'?s?) (?:expected )?return|expected 12[- ]month return|net expectation)\b" | |
| RETURN_DIRECTION = {"NEGATIVE": r"\b(?:bearish|negative|downside|a loss|below zero|lower)\b", | |
| "POSITIVE": r"\b(?:bullish|positive|upside|a gain|above zero)\b"} | |
| def return_direction_violation(sentence, own_value): | |
| """A direction written about the author's own expected return that its signed value contradicts ("expected return | |
| … tilted slightly bearish" for +6.9%).""" | |
| if own_value is None: | |
| return None | |
| for seg in re.split(r"[;:—–]|\b(?:but|while|whereas|because|since|although)\b", TOKEN.sub(" ", sentence).lower()): | |
| if not re.search(RETURN_SUBJECT, seg): | |
| continue | |
| for sign, rx in RETURN_DIRECTION.items(): | |
| m = re.search(rx, seg) | |
| if m and not re.search(r"\b(?:not|no|never)\W+(?:\w+\W+){0,2}$", seg[:m.start()]) \ | |
| and ((sign == "NEGATIVE") != (own_value < 0)) and abs(own_value) > 0.0005: | |
| return (f"'{m.group(0)}' describes the expected return, whose value is " | |
| f"{format_percent(own_value, 1, sign=True)}") | |
| return None | |
| def phrase_slot_violation(sentence, catalog): | |
| """Typed render slots for claims that are not noun phrases: a PREDICATE phrase ("down 11.8% over twelve months") | |
| follows a linking verb or its subject ("shares are …", "with shares …"), never a bare preposition ("With down …"); | |
| a LIST rendering (items joined by semicolons) ends its sentence.""" | |
| for m in TOKEN.finditer(sentence): | |
| cl = catalog.get(m.group(1)) | |
| slot = getattr(C_BY_ID.get(m.group(1)), "slot", "") if C_BY_ID else "" | |
| before = sentence[:m.start()].rstrip() | |
| if slot == "PREDICATE" and before and before[-1] not in ",;:(—–": | |
| prev = (re.findall(r"[A-Za-z'’]+", before) or [""])[-1].lower() | |
| if prev not in LINKING + PREDICATE_SUBJECTS: | |
| return (f"[[{m.group(1)}]] is a predicate ('{cl.rendered}'); it follows a verb or its subject " | |
| f"(\"shares are …\"), not '{prev}'") | |
| r = str(getattr(cl, "rendered", "") or "") | |
| if getattr(cl, "stype", None) == "TEXT" and ";" in r and sentence[m.end():].strip(" .!?\"')") != "": | |
| return f"[[{m.group(1)}]] is a list ('{r[:40]}…'); it must end its sentence" | |
| return None | |
| PREDICATE_SUBJECTS = ("shares", "stock", "price", "equity", "it", "they", "which", "been", "stay", "stays") | |
| # the concept a money figure measures, from its claim path: its own clause must name it ("requires $33.00B" names no | |
| # concept; "revenue of $33.00B" does) | |
| MONEY_CONCEPT_NAMES = (("revenue", ("revenue", "sales", "top line", "top-line")), | |
| ("tam", ("market", "addressable", "tam")), | |
| ("operating_income", ("operating income", "operating profit", "ebit")), | |
| ("net_income", ("net income", "earnings", "profit")), | |
| ("market_cap", ("market cap", "capitali", "market value")), | |
| ("insiders", ("insider", "purchase", "bought", "sale", "sold", "buying", "selling"))) | |
| MULTIPLE_NAMES = (("pe_", ("p/e", "pe ", "price-to-earnings", "earnings multiple", "multiple", "earnings")), | |
| ("peg", ("peg",)), ("ev_ebitda", ("ev/ebitda", "ebitda")), ("p_fcf", ("p/fcf", "free cash flow"))) | |
| def claim_label_violation(sentence, catalog): | |
| """A money figure (CURRENCY_BIG) or a multiple whose clause does not name what it measures.""" | |
| toks = list(TOKEN.finditer(sentence)) | |
| for i, m in enumerate(toks): | |
| cl = catalog.get(m.group(1)) | |
| st_ = getattr(cl, "stype", None) | |
| if st_ not in ("CURRENCY_BIG", "MULTIPLE"): | |
| continue | |
| path = str(getattr(cl, "path", "")).lower().split(".")[-1] + "_" | |
| table = MONEY_CONCEPT_NAMES if st_ == "CURRENCY_BIG" else MULTIPLE_NAMES | |
| names = next((ws for key, ws in table if key in (str(getattr(cl, "path", "")).lower() if st_ == "CURRENCY_BIG" else path)), None) | |
| if not names: | |
| continue | |
| lo = toks[i - 1].end() if i > 0 else 0 | |
| hi = toks[i + 1].start() if i + 1 < len(toks) else len(sentence) | |
| before = re.split(r"[;:(—–]", sentence[lo:m.start()])[-1] | |
| after = re.split(r"[,;:)—–.]", sentence[m.end():hi])[0] | |
| window = (before[-90:] + " " + after[:45]).lower() | |
| if not any(w in window for w in names): | |
| return f"[[{m.group(1)}]] ({cl.rendered}) is a {names[0].strip()} figure; its clause must name it ({', '.join(n.strip() for n in names[:3])})" | |
| return None | |
| def named_counterpart_violation(sentence, rules): | |
| """A cited claim compared with a quantity the sentence names but does not cite ("guides growth of [[GUIDANCE_4]], far | |
| below the break-even revenue growth the price demands"): the named quantity is that claim, and the pair must be | |
| compatible and connected by its relation.""" | |
| if rules.pair_fn is None or not C_BY_ID: | |
| return None | |
| low = _dash(TOKEN.sub(" ", sentence).lower().replace("’", "'")) | |
| if not (COMPARATIVE.search(low) or ORDER_UP.search(low) or ORDER_DOWN.search(low)): | |
| return None | |
| cited = [m.group(1) for m in TOKEN.finditer(sentence)] | |
| for y, d in C_BY_ID.items(): | |
| if not d.names or y in cited or y not in rules.catalog or not any(n in low for n in d.names): | |
| continue | |
| for x in cited: | |
| st = rules.pair_fn(x, y) | |
| if st is None: | |
| continue | |
| if st[0] == "OK": | |
| # a compatible pair still has a direction: the comparative must match the values | |
| up, dn = ORDER_UP.search(low) or re.search(r"\bhigher\b", low), ORDER_DOWN.search(low) or re.search(r"\blower\b", low) | |
| rng, yv = (rules.range_fn(x) if rules.range_fn else None), getattr(rules.catalog.get(y), "value", None) | |
| xv = getattr(rules.catalog.get(x), "value", None) | |
| lo_, hi_ = ((rng[0], rng[1]) if rng else (xv, xv)) if (rng or isinstance(xv, (int, float))) else (None, None) | |
| if isinstance(yv, (int, float)) and lo_ is not None and bool(up) != bool(dn): | |
| x_first = sentence.find(f"[[{x}]]") < min((low.find(n) for n in d.names if n in low), default=10 ** 6) | |
| says_x_above = bool(up) == x_first | |
| if (says_x_above and hi_ <= yv) or (not says_x_above and lo_ >= yv): | |
| return (f"states [[{x}]] is {'above' if says_x_above else 'below'} the {d.names[0]} ([[{y}]]), " | |
| "but the values say otherwise") | |
| continue | |
| if st[0] == "RELATION" and st[1] in cited: | |
| continue | |
| why = f"their relation is [[{st[1]}]]" if st[0] == "RELATION" else "; ".join(st[1])[:100] | |
| return f"compares [[{x}]] with the {d.names[0]} ([[{y}]]) without citing it ({why})" | |
| return None | |
| # ── verbal magnitudes ("high-teens EPS growth") are numbers: they must fit a claim of that metric ────────────── | |
| MAGNITUDE_BANDS = (("low-single-digit", 0.0, 0.04), ("mid-single-digit", 0.03, 0.07), ("high-single-digit", 0.06, 0.10), | |
| ("single-digit", 0.0, 0.10), ("low-double-digit", 0.09, 0.14), ("low-teens", 0.09, 0.14), | |
| ("mid-teens", 0.13, 0.17), ("high-teens", 0.16, 0.20), ("double-digit", 0.095, 1.0), | |
| ("low-twenties", 0.19, 0.24), ("mid-twenties", 0.23, 0.27), ("high-twenties", 0.26, 0.30)) | |
| MAGNITUDE_FAMILIES = {"RETURN": (("total return", "return", "upside", "downside"), | |
| ("SCENARIO_BEAR_RETURN", "SCENARIO_NEUTRAL_RETURN", "SCENARIO_BULL_RETURN", "PM_RETURN", | |
| "HEADLINE_RETURN", "TARGET_VS_PRICE", "RETURN_12M", "RETURN_3M"), ()), | |
| "EPS_GROWTH": (("eps", "earnings", "profits", "profit"), ("FORECAST_EPS_GROWTH",), ("EPS_GROWTH",)), | |
| "REVENUE_GROWTH": (("revenue", "sales", "top-line", "top line", "growth"), | |
| ("LIFECYCLE_FORWARD_GROWTH", "LIFECYCLE_TRAILING_GROWTH", "FORECAST_REVENUE_GROWTH"), | |
| ("REVENUE_GROWTH", "ORGANIC_REVENUE_GROWTH"))} | |
| def magnitude_violation(sentence, catalog): | |
| """A verbal magnitude band about a metric that no claim of that metric supports.""" | |
| low = _dash(TOKEN.sub(" ", sentence).lower()).replace(" to ", "-to-") | |
| for band, lo, hi in MAGNITUDE_BANDS: | |
| for m in re.finditer(r"(?:mid|low|high)?-?(?:to-)?" + re.escape(band) + r"|" + re.escape(band.replace("-", " ")), low): | |
| after = low[m.end():m.end() + 40] | |
| # the band binds to the nearest metric word after it ("mid-single-digit growth and elite profitability") | |
| pos = [(mm.start(), f) for f, (words, _c, _g) in MAGNITUDE_FAMILIES.items() for w in words | |
| for mm in re.finditer(r"\b" + re.escape(w) + r"\b", after)] | |
| if not pos: | |
| continue | |
| fam = min(pos)[1] | |
| words, cids, concepts = MAGNITUDE_FAMILIES[fam] | |
| vals = [abs(float(catalog[c].value)) if fam == "RETURN" else float(catalog[c].value) | |
| for c in cids if c in catalog and isinstance(catalog[c].value, (int, float))] | |
| for cid, c in catalog.items(): | |
| v = c.value if cid.startswith("GUIDANCE_") and isinstance(c.value, dict) else None | |
| if v and v.get("concept") in concepts: | |
| vals += [float(x) for x in (v.get("low"), v.get("high")) if isinstance(x, (int, float))] | |
| # a range like "mid-to-high single digit" widens to both bands | |
| wide_lo = lo - 0.01 - (0.03 if "to-" in m.group(0) else 0.0) | |
| if not any(wide_lo <= x <= hi + 0.01 for x in vals): | |
| shown = ", ".join(format_percent(x, 1, sign=True) for x in vals[:3]) or "none" | |
| return (f"'{m.group(0).replace('-to-', ' to ')}' fits no {fam.lower().replace('_', ' ')} claim " | |
| f"(available: {shown})") | |
| return None | |
| # spelled-out counts of the things COUNT claims count ("two adds and four new positions") are numbers without lineage | |
| SPELLED = r"\b(one|two|three|four|five|six|seven|eight|nine|ten|eleven|twelve|thirteen|fourteen|fifteen|sixteen|seventeen|" \ | |
| r"eighteen|nineteen|twenty|thirty|forty|fifty|hundred|thousand)\b" | |
| COUNTED = ("investor", "institution", "holder", "position", "filer", "add", "addition", "exit", "trim", "purchase", | |
| "exercise", "share", "analyst", "contributor", "opened", "added", "trimmed", "exited", "bought", "sold") | |
| # periods ("eight quarters", "ten years") are windows, not counts; segment counts are owned by SEGMENT_PERIMETER_LANGUAGE | |
| def spelled_count_violation(sentence): | |
| low = TOKEN.sub(" ", sentence).lower() | |
| for m in re.finditer(SPELLED, low): | |
| nxt = re.findall(r"[a-z]+", low[m.end():m.end() + 30])[:3] | |
| if m.group(1) == "one" and (not nxt or nxt[0] in ("of", "or", "another", "side", "way", "hand", "day", "time")): | |
| continue | |
| if any(w.startswith(c) for w in nxt for c in COUNTED): | |
| return f"'{m.group(0)} {' '.join(nxt[:2])}' spells out a count; cite its claim token instead" | |
| return None | |
| # a breakdown whose parts only make sense together: citing some parts and omitting the rest misstates the whole | |
| COMPLETE_SETS = (("OWN_13F_NEW", "OWN_13F_ADDS", "OWN_13F_TRIMS", "OWN_13F_EXITS"),) | |
| def complete_set_violation(sentence, catalog): | |
| toks = {m.group(1) for m in TOKEN.finditer(sentence)} | |
| for group in COMPLETE_SETS: | |
| present = [c for c in group if c in catalog] | |
| cited = [c for c in present if c in toks] | |
| if cited and len(cited) < len(present): | |
| missing = [c for c in present if c not in toks] | |
| return (f"cites part of a breakdown ({', '.join(cited)}) without the rest ({', '.join(missing)}); state all " | |
| "of it or none") | |
| return None | |
| # a price move "to <per-share value>" whose direction the two values contradict ("compression to $443.00" above $403.30) | |
| _MOVE_DOWN = r"\b(?:compress\w*|compression|fall\w*|fell|declin\w*|drop\w*|de-?rat\w*|slid\w*|slump\w*|contract\w*)\b" | |
| _MOVE_UP = r"\b(?:ris\w*|rose|rall\w*|re-?rat\w*|climb\w*|expan\w*|recover\w*|gain\w*)\b" | |
| def price_move_violation(sentence, catalog): | |
| price = getattr(catalog.get("PRICE"), "value", None) | |
| if not isinstance(price, (int, float)): | |
| return None | |
| for m in TOKEN.finditer(sentence): | |
| c = catalog.get(m.group(1)) | |
| if getattr(c, "stype", None) != "CURRENCY" or m.group(1) == "PRICE" or not isinstance(c.value, (int, float)): | |
| continue | |
| before = sentence[max(0, m.start() - 45):m.start()].lower() | |
| if not re.search(r"\bto\s*$", before): | |
| continue | |
| if re.search(_MOVE_DOWN, before) and c.value > price: | |
| return f"a decline 'to [[{m.group(1)}]]' ({c.rendered}) would be a rise: it is above the price ({catalog['PRICE'].rendered})" | |
| if re.search(_MOVE_UP, before) and c.value < price: | |
| return f"a rise 'to [[{m.group(1)}]]' ({c.rendered}) would be a fall: it is below the price ({catalog['PRICE'].rendered})" | |
| return None | |
| # an internal identifier (UPPER_SNAKE claim ids, lower_snake state fields): one class for authored objects (arguments.py | |
| # T2), legacy prose ingestion (check_sentence) and the report audit, so an object that would fail the audit is dropped | |
| # (and can be repaired) at authoring instead of withholding the whole report | |
| IDENTIFIER = re.compile(r"(?<!\[\[)\b(?:[a-z][a-z0-9]*(?:_[a-z0-9]+)+|[A-Z][A-Z0-9]*(?:_[A-Z0-9]+)+)\b(?!\]\])") | |
| def public_language_violation(sentence, terms): | |
| """Internal QA or state vocabulary (coherence checks, verification status, validators, the claim registry) in text a | |
| reader sees. The terms are the system's own internal states and fields, not a list of forbidden opinions.""" | |
| low = TOKEN.sub(" ", sentence).lower() | |
| hit = next((t for t in terms or () if re.search(r"\b" + re.escape(t) + r"\b", low)), None) | |
| return f"'{hit}' is internal QA language; describe the public limitation instead" if hit else None | |
| def placement_violation(sentence, rules): | |
| """A sentence placed as a reason for or against the view is directional by position: descriptive-only evidence | |
| (13F positions, immaterial insider trades, unclassified headlines) cannot be that reason.""" | |
| import evidence as _ev | |
| toks = {m.group(1) for m in TOKEN.finditer(sentence)} | |
| low = TOKEN.sub(" ", sentence).lower() | |
| for fam, d in (rules.interpretation or {}).items(): | |
| if d.get("permission") == _ev.DESCRIPTIVE_ONLY and (toks & set(d.get("claims") or ()) or any(w in low for w in d.get("subjects") or ())): | |
| return f"{fam} evidence is descriptive only; it cannot be listed as a reason for or against the view" | |
| news = (rules.evidence_uses or {}).get("NEWS") or {} | |
| if any(w in low for w in news.get("subjects") or ()): | |
| return "headlines are descriptive context only; they cannot be listed as a reason for or against the view" | |
| return None | |
| def evidence_scope_violation(sentence, rules): | |
| """Evidence about one model used in a sentence about another model.""" | |
| if not (rules.evidence_targets and rules.claim_model): | |
| return None | |
| toks = [rules.own_return_claim if m.group(1) == "OWN_RETURN" else m.group(1) for m in TOKEN.finditer(sentence)] | |
| for e in toks: | |
| t = rules.evidence_targets(e) | |
| if not t: | |
| continue | |
| src, targets = t | |
| for o in toks: | |
| mo = rules.claim_model(o) if o and o != e else None | |
| if mo and mo != src and mo not in targets: | |
| return f"[[{e}]] is evidence about {src}; it may not evaluate {mo} ([[{o}]])" | |
| return None | |
| def guidance_epistemic_violation(sentence, terms): | |
| """Presence or absence of guidance asserted for an item whose extraction was rejected, or absence asserted for | |
| an item that was only not found.""" | |
| low = sentence.lower() | |
| for concept, (state, names) in (terms or {}).items(): | |
| if state in ("AVAILABLE", "VERIFIED_ABSENT") or not any(n in low for n in names): | |
| continue | |
| if state == "FOUND_BUT_REJECTED" and (DENIAL.search(low) or GUIDANCE_WORDS.search(sentence)): | |
| return (f"{concept.lower().replace('_', ' ')} outlook: the filing's text failed verification, so neither its " | |
| "presence nor its absence may be stated") | |
| if state in ("NOT_FOUND", "UNKNOWN") and DENIAL.search(low) and GUIDANCE_WORDS.search(sentence): | |
| return f"{concept.lower().replace('_', ' ')} guidance was not found, which is not a verified absence" | |
| return None | |
| def polarity_violation(sentence, polarity): | |
| """Expectation polarity asserted without, or against, the ImpliedExpectations relation.""" | |
| import relations as _rel | |
| bare = TOKEN.sub(" ", sentence) | |
| pols = _rel.polarity_asserted(bare, structural=False) | |
| # a comparison between a fundamental and what the price implies explains the relation: it may paraphrase it without | |
| # the token, but never reverse it, and never exist without it | |
| for sp in _rel.polarity_asserted(bare, structural=True): | |
| if polarity is None: | |
| return "compares a fundamental with what the price implies, but no expectations relation exists for this issuer" | |
| if sp != polarity: | |
| return (f"compares a fundamental with what the price implies as {sp.lower().replace('_', ' ')}, but the " | |
| f"relation holds {polarity.lower().replace('_', ' ')}") | |
| if not pols: | |
| return None | |
| if "[[REL_EXPECTATIONS_STORY]]" not in sentence: | |
| return "asserts how demanding the price is relative to the story without citing [[REL_EXPECTATIONS_STORY]]" | |
| if polarity not in pols: | |
| return f"asserts {pols[0].replace('EXPECTATIONS_', '').lower().replace('_', ' ')} but the relation holds {polarity}" | |
| return None | |
| # the direction words PHRASE claims render themselves (catalog phrase templates): writing one before them doubles it | |
| def _dash(t): | |
| """One hyphen: the non-breaking, figure and en dashes an author types are the same word joiner.""" | |
| return re.sub(r"[\u2010\u2011\u2012\u2013\u00ad]", "-", t) | |
| PHRASE_DIRECTION_WORDS = ("up", "down", "more", "fewer", "upside", "downside", "gain", "loss") | |
| def qualifier_doubled(sentence, catalog, own_return_claim=None): | |
| """A claim that renders its own qualifier written with it a second time: a direction after a gap or a relation | |
| clause ("…is 7.8% above consensus ($19.04) above consensus"), or a bound before a guidance value that already | |
| states it ("more than more than $7B"). The qualifier words are the claims' own (GAP/REL directions, guidance | |
| operators), not a comparison list.""" | |
| import guidance as _g | |
| for m in GAP_THEN_DIRECTION.finditer(sentence): | |
| cid = own_return_claim if m.group(1) == "OWN_RETURN" else m.group(1) | |
| if getattr(catalog.get(cid), "stype", None) in ("GAP", "REL"): | |
| return (f"CLAIM_QUALIFIER_DOUBLED: [[{m.group(1)}]] already states its direction; a second direction word inverts " | |
| "or doubles it") | |
| for m in TOKEN.finditer(sentence): | |
| cl = catalog.get(own_return_claim if m.group(1) == "OWN_RETURN" else m.group(1)) | |
| r = str(getattr(cl, "rendered", "") or "") | |
| # a unit symbol the claim already renders, typed again next to it ("$" + "$72.00B", "19.7x" + "x", "46.3%" + "%") | |
| b, a = sentence[:m.start()], sentence[m.end():] | |
| if (r.lstrip("+-").startswith("$") and b.rstrip(" ").endswith("$")) or \ | |
| (r.endswith("%") and a.lstrip(" ").startswith("%")) or (r.endswith("x") and re.match(r"\s*x\b", a)) or \ | |
| (r.endswith("bps") and re.match(r"\s*bps\b", a, re.I)): | |
| return f"CLAIM_QUALIFIER_DOUBLED: [[{m.group(1)}]] already renders its unit ({r}); do not type it again" | |
| # a claim that renders a descriptor ("mature-growth", "neutral/mixed") owns it: the words around it may not repeat it | |
| if getattr(cl, "stype", None) == "TEXT" and r and len(r) <= 40 and not any(ch.isdigit() for ch in r): | |
| around = (sentence[max(0, m.start() - 60):m.start()] + " " + sentence[m.end():m.end() + 40]).lower() | |
| if _dash(TOKEN.sub(" ", around)).find(_dash(r.lower())) >= 0: # "mature‑growth" (U+2011) is "mature-growth" | |
| return f"CLAIM_QUALIFIER_DOUBLED: [[{m.group(1)}]] already renders '{r}'; do not repeat it around the token" | |
| if getattr(cl, "stype", None) in ("PHRASE", "REL") and r: | |
| words = [w for w in re.findall(r"[a-z]+", r.lower()) if len(w) > 2] | |
| around = TOKEN.sub(" ", sentence[max(0, m.start() - 60):m.start()] + " " + sentence[m.end():m.end() + 60]).lower() | |
| tri = {" ".join(words[i:i + 3]) for i in range(len(words) - 2)} | |
| # the same tokenisation on both sides ("today's" is "today" on both) | |
| if any(t in " ".join(w for w in re.findall(r"[a-z]+", around) if len(w) > 2) for t in tri): | |
| return f"CLAIM_QUALIFIER_DOUBLED: [[{m.group(1)}]] already renders that descriptor; do not repeat it" | |
| if getattr(cl, "stype", None) == "PHRASE" and cl.rendered: | |
| before = sentence[max(0, m.start() - 14):m.start()].lower() | |
| if re.search(r"\b(?:a|an)\s*$", before) and re.match(r"(?:a|an)\s", cl.rendered): | |
| return f"CLAIM_QUALIFIER_DOUBLED: [[{m.group(1)}]] already carries its article ({cl.rendered})" | |
| if re.search(r"\b(?:" + "|".join(PHRASE_DIRECTION_WORDS) + r")\b[^.;:]*$", before): | |
| return f"CLAIM_QUALIFIER_DOUBLED: [[{m.group(1)}]] already carries its direction ({cl.rendered})" | |
| continue | |
| op = (getattr(cl, "value", None) or {}).get("operator") if getattr(cl, "stype", None) == "GUIDANCE" else None | |
| if op in _g._BOUND_WORDS and re.search(r"(?:" + "|".join(_g._BOUND_WORDS.values()) + r")\W*$", | |
| sentence[max(0, m.start() - 24):m.start()], re.I): | |
| return f"CLAIM_QUALIFIER_DOUBLED: [[{m.group(1)}]] already states its bound ({cl.rendered}); do not write it again" | |
| return None | |
| def boundary_repeat(sentence, catalog): | |
| """The word before a token repeats the first word its rendering starts with ("Today, [[REL_EXPECTATIONS_STORY]]" → | |
| "Today, today's price requires …").""" | |
| for m in TOKEN.finditer(sentence): | |
| r = str(getattr(catalog.get(m.group(1)), "rendered", "") or "") | |
| first = (re.findall(r"[a-z]+", r.lower()) or [""])[0] | |
| prev = (re.findall(r"[a-z]+", sentence[:m.start()].lower()) or [""])[-1] | |
| if first and len(first) > 3 and prev == first: | |
| return f"CLAIM_QUALIFIER_DOUBLED: [[{m.group(1)}]] already starts with '{first}'; do not write it before the token" | |
| return None | |
| LIFECYCLE_STAGES = ("start-up", "young-growth", "high-growth", "mature-growth", "mature-stable", "decline") | |
| _STAGE_NOUNS = r"(?:stage|phase|company|franchise|business|compounder|firm|name|profile|story)" | |
| def lifecycle_violation(sentence, catalog, company_words=()): | |
| """A life-cycle stage term for the company other than its typed stage ("durable high growth" for a mature-growth | |
| company). The stage words are the LIFECYCLE_STAGE claim's own vocabulary; a stage term scoped to a segment ("high | |
| growth in private markets") is not about the company.""" | |
| stage = str(getattr(catalog.get("LIFECYCLE_STAGE"), "rendered", "") or "").lower() | |
| if stage not in LIFECYCLE_STAGES: | |
| return None | |
| low = _dash(TOKEN.sub(lambda t: " " + t.group(1).lower() + " ", sentence).lower()) | |
| for other in LIFECYCLE_STAGES: | |
| if other == stage: | |
| continue | |
| spaced = other.replace("-", "[ -]") | |
| for m in re.finditer(r"\b" + spaced + r"\b", low): | |
| after, before = low[m.end():m.end() + 30], low[:m.start()] | |
| if re.match(r"\s+(?:in|from|across|at|within|for)\b", after) or re.search(r"\b(?:not|no longer|unlike|past|from)\W+(?:\w+\W+){0,1}$", before): | |
| continue | |
| noun_after = re.match(r"\s*" + _STAGE_NOUNS, after) | |
| if other == "decline" and not noun_after: | |
| continue # "a revenue decline" is not the decline stage | |
| about_company = noun_after or any(w and w in before for w in company_words) or "lifecycle_stage" in low | |
| if about_company: | |
| return f"'{m.group(0)}' names a {other} company; its typed life-cycle stage is {stage}" | |
| return None | |
| def directional_violation(sentence, restricted): | |
| """The restricted evidence family a sentence uses to carry a direction, or None.""" | |
| low = sentence.lower() | |
| if not DIRECTIONAL.search(sentence) or DENIAL.search(low) and re.search(r"not (?:a )?(?:return )?signal|no (?:return )?signal", low): | |
| return None | |
| for fam, rx in (("13F", EVIDENCE_13F), ("insider", EVIDENCE_INSIDER)): | |
| if fam in (restricted or ()) and rx.search(sentence): | |
| return fam | |
| return None | |
| def relative_violation(sentence, allowed): | |
| """A rich/cheap/premium/discount-versus-peers statement without an allowed relative-valuation claim.""" | |
| if not (RELVAL.search(sentence) and PEER_WORDS.search(sentence)): | |
| return None | |
| named = [k for k, rx in _REL_METRIC.items() if re.search(rx, sentence, re.I)] | |
| if named: | |
| bad = [k for k in named if k not in (allowed or ())] | |
| return bad[0] if bad else None | |
| return None if allowed else "any" | |
| DENIAL = re.compile(r"\b(?:not|no|never|neither|nor|without|isn't|is not|are not|does not|do not apply|inapplicable|excluded|disabled)\b", re.I) | |
| try: | |
| from catalog import BY_ID as C_BY_ID # render slots are declared on the claim definitions | |
| except Exception: # noqa: BLE001 | |
| C_BY_ID = {} | |
| class Fragment: | |
| text: str | |
| claim_id: str | None = None | |
| value: object = None | |
| origin: str = "system" # "llm" for fragments ingested from an author; templates are "system" | |
| def to_dict(self): | |
| return {"text": self.text, "claim_id": self.claim_id, "value": self.value} | |
| class Rejection: | |
| source: str | |
| sentence: str | |
| reason: str | |
| def to_dict(self): | |
| return {"source": self.source, "sentence": self.sentence, "reason": self.reason} | |
| class Rules: | |
| catalog: dict | |
| engine_forbidden: tuple = () | |
| quant_evidence_allowed: bool = False # the single-stock price model's own permission | |
| panel_evidence_allowed: bool = False # the cross-sectional rank's own permission (never OR'd) | |
| panel_forbidden_phrases: tuple = () | |
| quant_forbidden_phrases: tuple = () | |
| plan_sales_present: bool = False # governance framing of scheduled sales is rejected | |
| guidance_available: bool = False # informational; the rule itself keys on GUIDANCE_n tokens | |
| compare_fn: object = None # (claim_a, claim_b) -> (ok, reasons) | None (catalog.comparison) | |
| counterparts: dict = field(default_factory=dict) # claim -> incomparable counterparts (catalog.implicit_counterparts) | |
| descriptors: dict = field(default_factory=dict) # claim -> (family, words describing it) (catalog.claim_descriptors) | |
| range_fn: object = None # claim -> (low, high, kind) | None (catalog.claim_range) | |
| restricted_evidence: tuple = () # evidence families that may not carry a direction: "13F", "insider" | |
| relative_allowed: tuple = () # peer metrics with an allowed RelativeValuationClaim | |
| offered: object = None # claim ids an author may cite (catalog.offered); None = unrestricted | |
| pair_fn: object = None # (a, b) -> None | ("RELATION", rel_id) | ("OK",) | ("INCOMPATIBLE", why) | |
| magnitude_bindings: dict = field(default_factory=dict) # magnitude claim -> (lhs claim, rhs claim) it measures | |
| claim_model: object = None # claim id -> model id (evidence.claim_model) | |
| evidence_targets: object = None # claim id -> (source model, [allowed target models]) | None | |
| guidance_terms: dict = field(default_factory=dict) # concept -> (epistemic state, (names a reader uses)) | |
| expectations_polarity: str | None = None # REL_EXPECTATIONS_STORY relation_type, or None when none exists | |
| presentation_claims: tuple = () # report-only relation magnitudes (catalog.RELATION_PRESENTATION) | |
| phrase_of: dict = field(default_factory=dict) # signed claim -> the phrase claim that carries its direction | |
| full_clause: object = None # claim id -> True when it renders a complete clause (catalog.is_full_clause) | |
| rel_subjects: dict = field(default_factory=dict) # REL claim id -> (lhs subject words, rhs subject words) | |
| trends_allowed: dict = field(default_factory=dict) # TREND claim id -> trend class, for approved TrendRelationships only | |
| change_claims: tuple = () # typed CHANGE / MARGIN_CHANGE claims that carry a level change themselves | |
| interpretation: dict = field(default_factory=dict) # evidence family -> {permission, claims, subjects} (evidence.interpretation) | |
| guidance_names: dict = field(default_factory=dict) # GUIDANCE_n -> (concept, names a reader uses for that concept) | |
| alternative_cases: dict = field(default_factory=dict) # case -> claim ids (ImpliedExpectations ALTERNATIVE_CONDITIONALS) | |
| case_variables: dict = field(default_factory=dict) # case -> the variable names a paraphrase of that case uses | |
| segment_perimeter: dict = field(default_factory=dict) # {"historical_claims", "qualifiers"} when a separation exists | |
| evidence_uses: dict = field(default_factory=dict) # EvidenceUse per evidence family (evidence.evidence_uses) | |
| internal_terms: tuple = () # internal QA/state vocabulary that may not reach a reader | |
| directional_context: bool = False # the sentence is a reason FOR or AGAINST the view ("Why" / risks) | |
| valuation_relations: dict = field(default_factory=dict) # REL id -> relation type, for the valuation anchors | |
| valuation_magnitudes: dict = field(default_factory=dict) # REL id -> signed gap, for the valuation anchors | |
| stock_returns: dict = field(default_factory=dict) # stock-return claim -> signed value (StockPerformance) | |
| assessments: dict = field(default_factory=dict) # story assumption -> typed AssumptionAssessment | |
| assumption_claims: tuple = () # the story-assumption claim ids | |
| transcript_turns: list | None = None # [{speaker, role, text}] of the call excerpt (TranscriptEvidence) | |
| company_words: tuple = () # the issuer's own names (a quote attributed to the company) | |
| statements: dict = field(default_factory=dict) # claim id -> financial statement it belongs to | |
| officers: dict = field(default_factory=dict) # role → canonical name | |
| own_return_claim: str | None = None # the claim [[OWN_RETURN]] resolves to | |
| own_label: str | None = None # this author's rating label (one rating table) | |
| held_labels: tuple = () # every rating label the analysis actually holds | |
| def _split(text): | |
| t = re.sub(r"\s+", " ", str(text or "")).strip() | |
| return [x for x in SENT.split(t) if x.strip()] if t else [] | |
| def _residual(sentence): | |
| """The sentence with tokens and benign numerals removed — what must contain no material number.""" | |
| return BENIGN.sub(" ", TOKEN.sub(" ", sentence)) | |
| LABEL_WINDOW = 48 | |
| # a label never reaches across a sentence end, a clause boundary, a comma, or a comparison word: in | |
| # "[[A]] trailing vs [[B]]" or "from [[A]] to [[B]]" the words before the conjunction belong to A | |
| CLAUSE = re.compile(r"[:;—–(),]|[.!?](?:\s|$)|\b(?:vs\.?|versus|against|compared with|from|to|and)\b", re.I) | |
| RATING_WORD = re.compile(r"\b((?i:strong[ -]buy|strong[ -]sell)|BUY|SELL|HOLD)\b|\b((?:strong[ -])?(?:buy|sell|hold))[ -](?:view|call|rating|recommendation|stance)\b") | |
| FIRST_PERSON = re.compile(r"\b(?:I|my|we|our|me)\b") | |
| GAP_THEN_DIRECTION = re.compile(r"\[\[([A-Z0-9_]+)\]\]\s+(?:above|below|over|under)\b", re.I) | |
| def label_violations(fragments, prohibited_for): | |
| """[(claim_id, label)] for every claim fragment preceded by a label that claim forbids. | |
| ONE rule, used by prose ingestion and by the final report audit alike: the words that label a | |
| claim are the text between the previous claim (or the start of the phrase) and this claim, at | |
| most LABEL_WINDOW characters. A label that belongs to an earlier claim is never attributed to | |
| a later one, and the rule reads rendered text, so it gives the same answer at both points.""" | |
| out, ctx = [], "" | |
| for f in fragments: | |
| if not f.claim_id: | |
| ctx += f.text | |
| continue | |
| before = CLAUSE.split(ctx[-LABEL_WINDOW:])[-1].lower() | |
| for bad in (prohibited_for(f.claim_id) or ()): | |
| if bad.lower() in before and not DENIAL.search(before[-24:]): | |
| out.append((f.claim_id, bad)) | |
| ctx = "" | |
| return out | |
| def check_sentence(sentence, rules: Rules): | |
| """(ok, reason). One evaluation; the sentence is never modified here.""" | |
| for m in TOKEN.finditer(sentence): | |
| cid = m.group(1) | |
| if cid == "OWN_RETURN": | |
| if not rules.own_return_claim or rules.own_return_claim not in rules.catalog: | |
| return False, "own-return token used where no own return exists" | |
| cid = rules.own_return_claim | |
| if rules.catalog.get(cid) is None: | |
| return False, f"unknown or unavailable claim token [[{m.group(1)}]]" | |
| rv1 = relation_violation(sentence, Rules(catalog=rules.catalog, magnitude_bindings=rules.magnitude_bindings, | |
| pair_fn=rules.pair_fn)) if rules.magnitude_bindings else None | |
| if rv1 and rv1[0] == "RELATION_MAGNITUDE": | |
| return False, f"{rv1[0]}: {rv1[1]}" | |
| ov = offered_violation(sentence, rules) | |
| if ov: | |
| return False, f"{ov[0]}: {ov[1]}" | |
| for cid, bad in label_violations(to_fragments(sentence, rules), lambda c: rules.catalog[c].prohibited): | |
| return False, f"[[{cid}]] labelled '{bad}', a label that claim forbids" | |
| dq = qualifier_doubled(sentence, rules.catalog, rules.own_return_claim) or operand_doubled(sentence, rules.catalog) \ | |
| or boundary_repeat(sentence, rules.catalog) | |
| if dq: | |
| return False, dq | |
| for m in RATING_WORD.finditer(sentence): | |
| lab = re.sub(r"[ -]+", " ", (m.group(1) or m.group(2) or "")).upper() | |
| if rules.held_labels and lab not in rules.held_labels: | |
| return False, f"rating word '{m.group(0)}' is not a rating this analysis holds" | |
| if rules.own_label and FIRST_PERSON.search(sentence) and lab != rules.own_label: | |
| return False, f"states its own view as '{lab}' but its rating is {rules.own_label}" | |
| if re.search(r"(?:…|\.\.\.)\W*$", sentence.strip()) or not re.search(r"[.!?][\"'”’)\]]*$", sentence.strip()) \ | |
| and len(sentence.split()) > 12: | |
| return False, "INCOMPLETE_SENTENCE: the sentence is cut off; only complete sentences are published" | |
| mgv = magnitude_violation(sentence, rules.catalog) | |
| if mgv: | |
| return False, f"VERBAL_MAGNITUDE: {mgv}" | |
| scv = spelled_count_violation(sentence) | |
| if scv: | |
| return False, f"FINAL_MATERIAL_CLAIM_LINEAGE: {scv}" | |
| lc = lifecycle_violation(sentence, rules.catalog, rules.company_words) | |
| if lc: | |
| return False, f"CLAIM_QUALIFIER_DOUBLED: {lc}" | |
| cs = complete_set_violation(sentence, rules.catalog) | |
| if cs: | |
| return False, f"EVIDENCE_INTERPRETATION: {cs}" | |
| lb = claim_label_violation(sentence, rules.catalog) | |
| if lb: | |
| return False, f"CLAIM_LABEL_BINDING: {lb}" | |
| for m in TOKEN.finditer(sentence): | |
| c1 = rules.catalog.get(m.group(1)) | |
| nxt = (re.findall(r"[A-Za-z]+", sentence[m.end():m.end() + 25]) or [""])[0].lower() | |
| if getattr(c1, "stype", None) == "COUNT" and getattr(c1, "value", None) is not None and round(float(c1.value)) == 1 \ | |
| and re.match(r"[a-z]{3,}[^su]s$", nxt) and nxt not in ("was", "has", "is", "this", "its"): | |
| return False, f"[[{m.group(1)}]] is 1; '{nxt}' is plural" | |
| res = _residual(sentence) | |
| mm = MATERIAL_NUMBER.search(res) | |
| if mm: | |
| return False, f"material number typed as text ('{mm.group(0).strip()}') instead of a claim token" | |
| low = sentence.lower() | |
| for t in rules.engine_forbidden or (): | |
| if re.search(r"\b" + re.escape(t.lower()) + r"\b", low) and not DENIAL.search(low): | |
| return False, f"valuation term '{t}' is not used by this issuer's valuation engine" | |
| panel_words = PANEL_TOPIC.search(sentence) | |
| price_words = PRICE_MODEL_TOPIC.search(PANEL_TOPIC.sub(" ", sentence)) | |
| if panel_words and not rules.panel_evidence_allowed and not DENIAL.search(low): | |
| return False, "MODEL_EVIDENCE_VISIBILITY: cites the cross-sectional rank, which is not usable as evidence for this analysis" | |
| if price_words and not rules.quant_evidence_allowed and not DENIAL.search(low): | |
| return False, "MODEL_EVIDENCE_VISIBILITY: cites the price model, which is not usable as evidence for this analysis" | |
| for words, phrases in ((price_words, rules.quant_forbidden_phrases), (panel_words, rules.panel_forbidden_phrases)): | |
| for p in phrases or (): | |
| if words and re.search(r"\b" + re.escape(p.lower()) + r"\b", low): | |
| return False, f"describes the quantitative model as '{p}', stronger than its reliability allows" | |
| if rules.plan_sales_present: | |
| for p in PLAN_SALE_BAD: | |
| for m in re.finditer(p, sentence, re.I): | |
| # "no … negative conviction signal" states the canonical finding; only an assertion counts | |
| if not DENIAL.search(CLAUSE.split(sentence[max(0, m.start() - 40):m.start()])[-1]): | |
| return False, "describes scheduled Rule 10b5-1 sales as conviction or a governance problem" | |
| if GUIDANCE_WORDS.search(sentence) and not GUIDANCE_TOKEN.search(sentence) and not DENIAL.search(low): | |
| return False, ("describes company guidance without citing a verified [[GUIDANCE_n]] claim" | |
| + ("" if rules.guidance_available else " (company guidance is unavailable in canonical data)")) | |
| for m in TOKEN.finditer(sentence): | |
| cid = m.group(1) | |
| if cid != "OWN_RETURN" and len(re.findall(re.escape(m.group(0)), sentence)) > 1: | |
| return False, f"[[{cid}]] repeated in one sentence" | |
| jm = re.search(r"\]\]\s*\[\[", sentence) | |
| if jm: | |
| return False, "two claims written back to back with nothing between them" | |
| tv = token_structure_violation(sentence, rules.catalog) | |
| if tv: | |
| return False, tv | |
| rv0 = relation_violation(sentence, rules) | |
| if rv0: | |
| return False, f"{rv0[0]}: {rv0[1]}" | |
| ms = mode_subject_violation(sentence, rules.rel_subjects) | |
| if ms: | |
| return False, f"RELATION_IDENTITY: {ms}" | |
| cg = clause_grammar_violation(sentence, rules.full_clause) | |
| if cg: | |
| return False, f"RELATION_CLAUSE_GRAMMAR: {cg}" | |
| ed0 = evidence_domain_violation(sentence, rules) | |
| if ed0: | |
| return False, f"EVIDENCE_DOMAIN_SCOPE: {ed0}" | |
| tr = trend_violation(sentence, rules) | |
| if tr: | |
| return False, f"TREND_RELATION_REQUIRED: {tr}" | |
| iv0 = interpretation_violation(sentence, rules.interpretation) | |
| if iv0: | |
| return False, f"EVIDENCE_INTERPRETATION: {iv0}" | |
| if rules.directional_context: | |
| dc = placement_violation(sentence, rules) | |
| if dc: | |
| return False, f"EVIDENCE_INTERPRETATION: {dc}" | |
| gb = guidance_binding_violation(sentence, rules.guidance_names) | |
| if gb: | |
| return False, f"GUIDANCE_LABEL_BINDING: {gb}" | |
| rl = relation_logic_violation(sentence, rules.alternative_cases, rules.case_variables) | |
| if rl: | |
| return False, f"RELATION_LOGIC_PRESERVATION: {rl}" | |
| rs_ = relation_slot_violation(sentence, rules.rel_subjects, rules.catalog) | |
| if rs_: | |
| return False, f"RELATION_CLAUSE_GRAMMAR: {rs_}" | |
| vr = valuation_relation_violation(sentence, rules.valuation_relations) or \ | |
| universal_valuation_violation(sentence, rules.valuation_relations) | |
| if vr: | |
| return False, f"VALUATION_RELATION_LANGUAGE: {vr}" | |
| ed = evidence_domain_violation(sentence, rules) | |
| if ed: | |
| return False, f"EVIDENCE_DOMAIN_SCOPE: {ed}" | |
| sg = segment_perimeter_violation(sentence, rules.segment_perimeter) | |
| if sg: | |
| return False, f"SEGMENT_PERIMETER_LANGUAGE: {sg}" | |
| ec = evidence_conclusion_violation(sentence, rules.evidence_uses) | |
| if ec: | |
| return False, f"{ec[0]}: {ec[1]}" | |
| ps = phrase_slot_violation(sentence, rules.catalog) | |
| if ps: | |
| return False, f"RELATION_CLAUSE_GRAMMAR: {ps}" | |
| fc = family_comparison_violation(sentence) | |
| if fc: | |
| return False, f"RELATION_REQUIRED_FOR_PAIR: {fc}" | |
| px = proximity_violation(sentence, rules.valuation_magnitudes) | |
| if px: | |
| return False, f"VALUATION_RELATION_LANGUAGE: {px}" | |
| own_cid = rules.own_return_claim or ("HEADLINE_RETURN" if "HEADLINE_RETURN" in rules.catalog else None) | |
| own_v = getattr(rules.catalog.get(own_cid), "value", None) if own_cid else None | |
| rd = return_direction_violation(sentence, own_v if isinstance(own_v, (int, float)) else None) | |
| if rd: | |
| return False, f"RETURN_DIRECTION_CONSISTENCY: {rd}" | |
| pf = performance_violation(sentence, rules.stock_returns, rules.company_words) | |
| if pf: | |
| return False, f"STOCK_PERFORMANCE_CLAIM: {pf}" | |
| av = assessment_violation(sentence, rules.assessments, rules.assumption_claims) | |
| if av: | |
| return False, f"ASSUMPTION_ASSESSMENT: {av}" | |
| qv = quote_violation(sentence, rules.transcript_turns, rules.company_words) or \ | |
| paraphrase_attribution_violation(sentence, rules.transcript_turns, rules.company_words) | |
| if qv: | |
| return False, f"QUOTE_ATTRIBUTION: {qv}" | |
| sd = statement_domain_violation(sentence, rules.statements) | |
| if sd: | |
| return False, f"STATEMENT_DOMAIN: {sd}" | |
| mv_ = price_move_violation(sentence, rules.catalog) | |
| if mv_: | |
| return False, f"VALUATION_RELATION_LANGUAGE: {mv_}" | |
| raw = IDENTIFIER.findall(TOKEN.sub(" ", sentence)) | |
| if raw: | |
| return False, f"PUBLIC_LANGUAGE_ONLY: internal identifier '{raw[0]}' written as text" | |
| pl = public_language_violation(sentence, rules.internal_terms) | |
| if pl: | |
| return False, f"PUBLIC_LANGUAGE_ONLY: {pl}" | |
| ev = evidence_scope_violation(sentence, rules) | |
| if ev: | |
| return False, f"MODEL_EVIDENCE_SCOPE: {ev}" | |
| gv = guidance_epistemic_violation(sentence, rules.guidance_terms) | |
| if gv: | |
| return False, f"GUIDANCE_EPISTEMIC_STATE: {gv}" | |
| pv = polarity_violation(sentence, rules.expectations_polarity) | |
| if pv: | |
| return False, f"EXPECTATIONS_POLARITY: {pv}" | |
| ov = ordering_violation(sentence, rules.range_fn) | |
| if ov: | |
| return False, f"orders [[{ov[0]}]] against [[{ov[1]}]]: {ov[2][0]}" | |
| nc = named_counterpart_violation(sentence, rules) | |
| if nc: | |
| return False, f"RELATION_REQUIRED_FOR_PAIR: {nc}" | |
| iv = implicit_comparison_violation(sentence, rules.counterparts, rules.descriptors) | |
| if iv: | |
| return False, (f"compares [[{iv[0]}]] with its counterpart [[{iv[1]}]] without citing it, and they are not comparable " | |
| f"({'; '.join(iv[2])[:120]})") | |
| dv = directional_violation(sentence, rules.restricted_evidence) | |
| if dv: | |
| return False, (f"uses {'13F ownership' if dv == '13F' else 'insider trading'} as a directional signal, which its evidence " | |
| "permissions do not allow (state the positions or trades only)") | |
| rv = relative_violation(sentence, rules.relative_allowed) | |
| if rv: | |
| return False, (f"describes the stock as rich/cheap against peers on {'a multiple' if rv == 'any' else rv} without a " | |
| "comparable peer basis") | |
| mv = motive_violation(sentence) | |
| if mv: | |
| return False, (f"infers a motive ('{mv[1]}') from {'Form 13F ownership' if mv[0] == '13F' else 'insider (Form 4)'} " | |
| "filings, which record positions and trades, not intent") | |
| for m in TOKEN.finditer(sentence): | |
| unit = getattr(C_BY_ID.get(m.group(1)), "unit_after", "") if C_BY_ID else "" | |
| if unit and not re.match(r"[\s\-\u2011]*(?:\w+\s+){0,1}" + unit, sentence[m.end():m.end() + 30], re.I): | |
| return False, f"[[{m.group(1)}]] is a count of {unit}s; write the unit after it (\"[[{m.group(1)}]] {unit}s\")" | |
| for m in TOKEN.finditer(sentence): | |
| cid = rules.own_return_claim if m.group(1) == "OWN_RETURN" else m.group(1) | |
| req = getattr(rules.catalog.get(cid), "required_qualifiers", ()) or () | |
| if req: | |
| before = sentence[max(0, m.start() - 60):m.start()].lower() | |
| if not any(q in before for q in req): | |
| return False, f"[[{m.group(1)}]] must be written as {req[0]} (one of: {', '.join(req[:4])})" | |
| for m in ROLE_NAME.finditer(sentence): | |
| role = m.group(1).upper() | |
| role = {"CHIEF EXECUTIVE OFFICER": "CEO", "CHIEF FINANCIAL OFFICER": "CFO", "CHIEF OPERATING OFFICER": "COO"}.get(role, role) | |
| canon = (rules.officers or {}).get(role) | |
| name = m.group(2).strip() | |
| if canon and not _same_person(name, canon): | |
| return False, f"names {name} as {role}; the canonical {role} is {canon}" | |
| if not canon: | |
| return False, f"names a {role} although no {role} is verified for this company" | |
| return True, "" | |
| def motive_violation(sentence): | |
| """('13F' | 'insider', word) when a sentence attributes a motive to institutional holders or insiders; else None. | |
| One rule for prose ingestion and the report audit (S10, S11).""" | |
| for m in MOTIVE_WORD.finditer(sentence): | |
| if DENIAL.search(CLAUSE.split(sentence[max(0, m.start() - 40):m.start()])[-1]): | |
| continue | |
| win = sentence[max(0, m.start() - MOTIVE_WINDOW): m.end() + MOTIVE_WINDOW] | |
| if MOTIVE_ACTOR_13F.search(win): | |
| return "13F", m.group(0) | |
| if MOTIVE_ACTOR_INSIDER.search(win) or (MOTIVE_ACTOR_MGMT.search(win) and TRADE_WORD.search(win)): | |
| return "insider", m.group(0) | |
| return None | |
| def guidance_violation(fragments): | |
| """True when a phrase describes company guidance without a GUIDANCE_n claim fragment (S9).""" | |
| text = "".join(f.text for f in fragments) | |
| if not GUIDANCE_WORDS.search(text) or DENIAL.search(text): | |
| return False | |
| return not any(str(f.claim_id or "").startswith("GUIDANCE_") for f in fragments) | |
| def qualifier_violation(fragments, required_for): | |
| """[(claim_id, qualifier list)] for claims written without their required qualifier (S12).""" | |
| out, ctx = [], "" | |
| for f in fragments: | |
| if f.claim_id: | |
| req = required_for(f.claim_id) or () | |
| if req and not any(q in ctx[-60:].lower() for q in req): | |
| out.append((f.claim_id, req)) | |
| ctx += f.text | |
| return out | |
| def _same_person(a, b): | |
| ta = {w.strip(".").lower() for w in a.split() if len(w.strip(".")) > 1} | |
| tb = {w.strip(".").lower() for w in b.split() if len(w.strip(".")) > 1} | |
| return bool(ta & tb) and (max(ta, key=len) in tb or max(tb, key=len) in ta) | |
| def to_fragments(sentence, rules: Rules): | |
| out, pos = [], 0 | |
| for m in TOKEN.finditer(sentence): | |
| if m.start() > pos: | |
| out.append(Fragment(sentence[pos:m.start()])) | |
| cid = m.group(1) | |
| if cid == "OWN_RETURN": | |
| cid = rules.own_return_claim | |
| c = rules.catalog[cid] | |
| txt = c.rendered | |
| import catalog as _cat | |
| if (c.stype == "REL" or getattr(_cat.BY_ID.get(cid), "clause", False)) and txt and not sentence[:m.start()].strip(): | |
| txt = txt[0].upper() + txt[1:] # a relation clause that opens the sentence starts with a capital | |
| out.append(Fragment(txt, c.claim_id, c.value)) | |
| pos = m.end() | |
| if pos < len(sentence): | |
| out.append(Fragment(sentence[pos:])) | |
| return out | |
| # a sentence that opens by pointing back ("Neither is credible.", "This means …") depends on what came before it | |
| ANAPHORIC_OPENING = re.compile(r"^\W*(?:this|that|these|those|it|its|they|their|them|neither|both|either|such|which|" | |
| r"the latter|the former|hence|thus|therefore|so|also|likewise|similarly|conversely|" | |
| r"otherwise|instead|then)\b", re.I) | |
| def ingest(text, rules: Rules, source): | |
| """(paragraph fragments, rejections). Each accepted sentence keeps its claim fragments. Once a sentence of the | |
| passage is removed, a later sentence that opens by referring back is removed too: its antecedent may be gone.""" | |
| frags, rej = [], [] | |
| removed = False | |
| for sent in _split(text): | |
| ok, why = check_sentence(sent, rules) | |
| if ok and removed and ANAPHORIC_OPENING.search(sent): | |
| ok, why = False, "DANGLING_REFERENCE: refers back to a sentence that was removed" | |
| if not ok: | |
| rej.append(Rejection(source, sent[:240], why)) | |
| removed = True | |
| continue | |
| if frags: | |
| frags.append(Fragment(" ", origin="llm")) | |
| for f in to_fragments(sent, rules): | |
| f.origin = "llm" | |
| frags.append(f) | |
| return frags, rej | |