From cc2c7a00fb52aa26cf82ee25087717e674edd6e8 Mon Sep 17 00:00:00 2001 From: ThearchyHelios Date: Wed, 21 Oct 2026 19:32:38 +0800 Subject: [PATCH] =?UTF-8?q?=E5=8F=AF=E6=AF=94=E6=80=A7=E6=9C=9F=E6=9C=9B?= =?UTF-8?q?=E8=AE=A4=E6=AD=A3=E6=96=87=E9=87=8C=E3=80=8C=E4=B8=A4=E6=9C=9F?= =?UTF-8?q?=E4=B8=8D=E8=83=BD=E7=9B=B4=E6=8E=A5=E6=AF=94=E3=80=8D=E7=9A=84?= =?UTF-8?q?=E8=AF=B4=E6=B3=95=EF=BC=9A=E8=AF=B4=E6=B3=95=E8=A1=A8=EF=BC=88?= =?UTF-8?q?engine/analysis=5Fclaims=EF=BC=89=E7=85=A7=E6=97=A7=E4=B8=8D?= =?UTF-8?q?=E5=8A=A0=E5=8F=AF=E6=AF=94=20/=20=E4=B8=8D=E5=8F=AF=E6=AF=94?= =?UTF-8?q?=E4=B8=80=E7=B1=BB=EF=BC=8C=E8=AF=B4=E6=B3=95=E6=A0=B8=E5=AF=B9?= =?UTF-8?q?=E4=B8=8D=E6=89=93=E5=9B=9E=E3=80=8C=E6=9C=AC=E5=91=A8=E6=95=B0?= =?UTF-8?q?=E6=8D=AE=E4=B8=8D=E5=AE=8C=E6=95=B4=EF=BC=8C=E4=B8=8D=E8=83=BD?= =?UTF-8?q?=E7=9B=B4=E6=8E=A5=E6=AF=94=E3=80=8D=E8=BF=99=E7=A7=8D=E8=B0=A8?= =?UTF-8?q?=E6=85=8E=E7=9A=84=E6=8F=90=E9=86=92=EF=BC=9B=E8=AF=84=E5=88=86?= =?UTF-8?q?=E5=9C=A8=20eval/judgements=20=E5=8F=A6=E7=94=A8=E4=B8=80?= =?UTF-8?q?=E5=BC=A0=E5=8F=AA=E7=BB=99=E8=87=AA=E5=B7=B1=E7=94=A8=E7=9A=84?= =?UTF-8?q?=E5=B0=8F=E8=AF=8D=E8=A1=A8=EF=BC=88=5FINCOMPARABLE=EF=BC=9A?= =?UTF-8?q?=E4=B8=8D=E8=83=BD=E7=9B=B4=E6=8E=A5=E6=AF=94=E3=80=81=E4=B8=8D?= =?UTF-8?q?=E5=8F=AF=E6=AF=94=E3=80=81=E4=B8=8D=E5=85=B7=E5=8F=AF=E6=AF=94?= =?UTF-8?q?=E6=80=A7=E3=80=81=E6=9C=AC=E6=9C=9F=E6=95=B0=E6=8D=AE=E4=B8=8D?= =?UTF-8?q?=E5=AE=8C=E6=95=B4=E3=80=81=E8=BF=98=E6=B2=A1=E8=BF=87=E5=AE=8C?= =?UTF-8?q?=E3=80=81=E5=8F=AA=E6=9C=89=E5=87=A0=E5=A4=A9=E3=80=81=E5=B7=AE?= =?UTF-8?q?=E5=87=A0=E5=A4=A9=E3=80=81=E4=B8=8D=E6=BB=A1=E4=B8=80=E5=91=A8?= =?UTF-8?q?=E3=80=81=E5=A4=A9=E6=95=B0=E4=B8=8D=E5=90=8C=E3=80=81=E5=8F=A3?= =?UTF-8?q?=E5=BE=84=E4=B8=8D=E5=90=8C=E2=80=A6=E2=80=A6=EF=BC=9B=E3=80=8C?= =?UTF-8?q?=E6=97=A0=E6=B3=95=E6=AF=94=E6=8B=9F=E3=80=8D=E3=80=8C=E6=AF=94?= =?UTF-8?q?=E4=B8=8D=E4=B8=8A=E3=80=8D=E3=80=81=E4=B8=8D=E5=B8=A6=E3=80=8C?= =?UTF-8?q?=E7=9B=B4=E6=8E=A5=E3=80=8D=E7=9A=84=E3=80=8C=E6=B2=A1=E6=B3=95?= =?UTF-8?q?=E6=AF=94=E3=80=8D=E4=B8=8D=E7=AE=97=EF=BC=89=E3=80=82=E6=9C=9F?= =?UTF-8?q?=E6=9C=9B=E6=98=AF=E4=B8=8D=E5=8F=AF=E6=AF=94=E3=80=81=E6=AD=A3?= =?UTF-8?q?=E6=96=87=E6=98=8E=E7=A1=AE=E8=BF=99=E4=B9=88=E8=AF=B4=E4=BA=86?= =?UTF-8?q?=E7=9A=84=EF=BC=8C=E6=8C=89=E3=80=8C=E5=AF=B9=E4=BD=86=E6=B2=A1?= =?UTF-8?q?=E5=BC=95=E7=94=A8=E3=80=8D=E7=AE=97=EF=BC=9B=E5=90=8C=E4=B8=80?= =?UTF-8?q?=E5=88=86=E5=8F=A5=E9=87=8C=E5=89=8D=E9=9D=A2=E6=9C=89=E5=90=A6?= =?UTF-8?q?=E5=AE=9A=E7=9A=84=E3=80=81=E5=8F=A5=E5=AD=90=E9=87=8C=E5=89=8D?= =?UTF-8?q?=E9=9D=A2=E6=9C=89=E5=81=87=E8=AE=BE=E6=88=96=E7=96=91=E9=97=AE?= =?UTF-8?q?=E7=9A=84=E3=80=81=E9=97=AE=E5=8F=A5=E9=83=BD=E4=B8=8D=E7=AE=97?= =?UTF-8?q?=EF=BC=9B=E6=9C=9F=E6=9C=9B=E6=98=AF=E5=8F=AF=E6=AF=94=E7=9A=84?= =?UTF-8?q?=E5=8F=AA=E8=83=BD=E9=9D=A0=E5=BC=95=E7=94=A8=E3=80=82=E8=AF=BB?= =?UTF-8?q?=E7=AD=94=E6=A1=88=E6=97=B6=E6=9C=89=E8=BF=99=E7=B1=BB=E8=AF=B4?= =?UTF-8?q?=E6=B3=95=E7=9A=84=E5=8F=A5=E5=AD=90=E4=B9=9F=E7=95=99=E4=B8=8B?= =?UTF-8?q?=EF=BC=88sentences=5Fof=5Fdoc=E3=80=81sentences=5Fof=5Ftext?= =?UTF-8?q?=EF=BC=89=E3=80=82=E6=B5=8B=E8=AF=95=EF=BC=9A=E5=87=A0=E7=A7=8D?= =?UTF-8?q?=E8=AF=B4=E6=B3=95=E7=AE=97=E5=AF=B9=E4=BD=86=E6=B2=A1=E5=BC=95?= =?UTF-8?q?=E7=94=A8=E3=80=81=E6=89=BE=E5=88=B0=E7=9A=84=E9=82=A3=E4=B8=80?= =?UTF-8?q?=E5=A4=84=E7=85=A7=E5=AE=9E=E5=86=99=EF=BC=9B=E5=81=87=E8=AE=BE?= =?UTF-8?q?=E3=80=81=E7=96=91=E9=97=AE=E3=80=81=E5=90=A6=E5=AE=9A=E3=80=81?= =?UTF-8?q?=E5=B7=AE=E5=BE=97=E8=BF=9C=E3=80=81=E6=97=A0=E5=85=B3=E7=9A=84?= =?UTF-8?q?=E3=80=8C=E4=B8=8D=E5=AE=8C=E6=95=B4=E3=80=8D=E7=AE=97=E9=94=99?= =?UTF-8?q?=EF=BC=9B=E6=9C=9F=E6=9C=9B=E5=8F=AF=E6=AF=94=E7=9A=84=E6=AD=A3?= =?UTF-8?q?=E6=96=87=E6=80=8E=E4=B9=88=E5=86=99=E9=83=BD=E4=B8=8D=E7=AE=97?= =?UTF-8?q?=EF=BC=9B=E8=AF=B4=E6=B3=95=E8=A1=A8=E7=85=A7=E6=97=A7=E4=B8=8D?= =?UTF-8?q?=E8=AE=A4=E8=BF=99=E7=B1=BB=E8=AF=B4=E6=B3=95=E3=80=82test=5Fdo?= =?UTF-8?q?ing=5Fnothing=5Fand=5Fsaying=5Fnothing=5Fis=5Fwrong=20=E9=87=8C?= =?UTF-8?q?=E5=8E=9F=E5=85=88=E3=80=8C=E6=9C=AC=E5=91=A8=E8=BF=98=E6=B2=A1?= =?UTF-8?q?=E8=BF=87=E5=AE=8C=EF=BC=8C=E4=B8=A4=E5=91=A8=E4=B8=8D=E8=83=BD?= =?UTF-8?q?=E7=9B=B4=E6=8E=A5=E6=AF=94=E3=80=8D=E7=AE=97=E9=94=99=E7=9A=84?= =?UTF-8?q?=E9=82=A3=E6=9D=A1=E6=96=AD=E8=A8=80=E6=8C=89=E6=96=B0=E8=A7=84?= =?UTF-8?q?=E5=88=99=E7=A7=BB=E5=88=B0=E6=96=B0=E6=B5=8B=E8=AF=95=E9=87=8C?= =?UTF-8?q?=EF=BC=8C=E6=94=B9=E6=88=90=E7=AE=97=E5=AF=B9=E4=BD=86=E6=B2=A1?= =?UTF-8?q?=E5=BC=95=E7=94=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/app/eval/judgements.py | 92 +++++++++++++++++++++++++---- backend/tests/test_eval_analysis.py | 39 ++++++++++-- 2 files changed, 114 insertions(+), 17 deletions(-) diff --git a/backend/app/eval/judgements.py b/backend/app/eval/judgements.py index d84e3c79..73b79cd3 100644 --- a/backend/app/eval/judgements.py +++ b/backend/app/eval/judgements.py @@ -21,7 +21,10 @@ comparable 可比(comparable、conditional)/ incomparable 不可比。 - 对且可追溯:答案引用了([[a:]]、[[v:A…]]、[[table:A…]]、[[see:A…]])判定对得上的分析结论(同一个算子,变化拆解还要同一种 模式;主要来源要点名期望的那几段,方向一致;差异检验比的是同两组);引用它的那句话里的判断词却和期望对不上的不算。 - 对但没引用:没引用对得上的结论,但正文按说法表(engine/analysis_claims,同一套词表、同一套绑定规则)写对了:说法表认出的 - 这一类判断词,拿期望的那份分析结论核对得上(点名的段、方向、连续几期都核)。可比性没有对应的判断词,只能引用。 + 这一类判断词,拿期望的那份分析结论核对得上(点名的段、方向、连续几期都核)。可比性不进说法表(说法核对不该打回「本周数据 + 不完整,不能直接比」这种谨慎的提醒),评分另用一张只给自己用的小词表(_INCOMPARABLE):期望是不可比的,正文明确说了两期 + 不能直接比(不能直接比、不可比、本期不完整、还没过完、只有几天、天数不同、口径不同……;分句里先有否定的、句子里先有假设 + 或疑问的、问句都不算)也算;期望是可比的只能靠引用。 - 错:引用的分析判定对不上、正文写的和期望相反(把常规波动说成异常、把上升说成下降、主要来源说成别的段),或者根本没做也 没说。 - 不能评分:期望算不了。 @@ -80,6 +83,38 @@ _AFFIRMS: dict[str, frozenset[str]] = { #: 判断词所在的句子最多留多少字给人看 _SNIPPET_MAX = 40 +#: 数字(阿拉伯、全角、汉字)、期次、分句里的字(不跨句读)、比 +_N = r"[0-90-9一二两三四五六七八九十]+" +_PERIOD = "周|月|季|年|期|天|日" +_IN_CLAUSE = r"[^,,;;。!?!?\n]" +_COMPARE = "比较|对比|相比|比" +#: 正文里明确说两期不能直接比的说法:直说不能比的,和说出不能直接比的原因的(本期不完整、还没过完、只有几天、天数不同、 +#: 口径不同)。只给评分用,不进说法表(engine/analysis_claims):说法核对不该打回「本周数据不完整,不能直接比」这种谨慎的 +#: 提醒 +_INCOMPARABLE = re.compile( + # 直说不能比:不能直接比、无法直接比较、不宜简单对比、难以比较……「无法比拟」「比不上」「比得上」不是 + rf"(?:不能|不可以|无法|不宜|不应该?|不便|难以|很难|不好|没办法)(?:直接|简单)?地?(?:拿来|放在一起)?" + rf"(?:{_COMPARE})(?![拟肩]|得上|不上)" + # 「没法」要带「直接」「简单」,或者说「比较」「对比」:光说「没法比」多半是说差得远 + rf"|没法(?:直接|简单)地?(?:拿来|放在一起)?(?:{_COMPARE})(?![拟肩]|得上|不上)|没法(?:比较|对比|相比)" + # 不能直接拿本周和上周比 + rf"|(?:不能|无法|不宜|不好|没法)(?:直接|简单)?(?:拿|把|将){_IN_CLAUSE}{{1,12}}?(?:和|跟|与|同){_IN_CLAUSE}{{1,12}}?" + rf"(?:{_COMPARE})(?![拟肩]|得上|不上)" + r"|不可比(?!拟)|不具备?可比性|没有可比性|缺乏可比性|可比性(?:不足|不够|存疑|较差|很差)" + # 原因:本期不完整、还没过完、只有几天、差几天、不满一周、天数不同、口径不同 + rf"|(?:数据|统计|{_PERIOD}){_IN_CLAUSE}{{0,4}}?(?:不完整|不全(?!是))" + rf"|(?:{_PERIOD}){_IN_CLAUSE}{{0,4}}?(?:还|尚)?(?:没|未)(?:过完|结束|走完)" + rf"|(?:只有|仅有|才有|只统计了|只算了|只过了|才过了|才过去|才)\s*{_N}\s*(?:天|日)" + rf"|(?:还差|少了?|相差|差了?)\s*{_N}\s*(?:天|日)" + r"|(?:不满|不足|不到)\s*(?:一|1)\s*整?(?:周|个?月|季度?|年)" + r"|(?:天数|期长|时长|长度)也?(?:不同|不一样|不一致|不等|不相等|对不上|不足|不够)" + r"|口径也?(?:不同|不一样|不一致|变了|有变|有变化|发生了?变化|调整过?|改过|改了|有调整)|(?:换了|改了|调整了)(?:统计)?口径" +) +#: 可比性的说法前面(同一分句里)有否定的不算说了:「不是不能比」「并非不可比」 +_INCOMPARABLE_DENIED = re.compile(r"不是|并非|并不|未必|不一定") +#: 句子里在它前面有假设、疑问的不算说了:「如果口径不同,就不能直接比」「是不是不能直接比」 +_INCOMPARABLE_SUPPOSED = re.compile(r"如果|假如|要是|倘若|假设|若是|是否|是不是|能不能|有没有") + class ParamsInvalid(ValueError): """期望的分析参数写得不对(保存题目时、算期望时都可能)。message 给人看,key 是出错的那个参数。""" @@ -362,10 +397,17 @@ def mismatch(want: Mapping[str, Any], got: Mapping[str, Any]) -> str | None: _SENTENCE_END = re.compile(r"(?<=[。!?!?\n])") +def _said_something(text: str) -> bool: + """这句话里有没有评分要看的说法:说法表的判断词,或者明确说两期不能直接比(_INCOMPARABLE)。""" + from app.engine import analysis_claims as said + + return bool(said.scan(text) or incomparable_spans(text)) + + def sentences_of_doc(doc: Mapping[str, Any], aliases: Iterable[str]) -> list[dict[str, Any]]: - """报告文档里有判断词的句子:{text: 写作者自己写的字(引用渲染出来的值、判定语、引文涂成 \\x00), names: 点名用的样子 - (引用渲染出来的值留着), analyses: 这句挂的分析结论编号}。标题、代码块、表格不算(同说法核对)。aliases 是文档目录里 - 分析结论的编号。""" + """报告文档里有判断词(或者明确说两期不能直接比)的句子:{text: 写作者自己写的字(引用渲染出来的值、判定语、引文涂成 + \\x00), names: 点名用的样子(引用渲染出来的值留着), analyses: 这句挂的分析结论编号}。标题、代码块、表格不算(同说法 + 核对)。aliases 是文档目录里分析结论的编号。""" from app.engine import analysis_claims as said from app.engine.evidence import iter_units @@ -376,19 +418,17 @@ def sentences_of_doc(doc: Mapping[str, Any], aliases: Iterable[str]) -> list[dic or unit.get("loc") is not None: continue sentence = said._sentence(unit, unit.get("cites") or [], known) - if sentence is not None and said.scan(sentence.text): + if sentence is not None and _said_something(sentence.text): out.append({"text": sentence.text, "names": sentence.names, "analyses": list(sentence.analyses)}) return out def sentences_of_text(text: str) -> list[dict[str, Any]]: - """没有报告文档时(成果里的文字):按句末标点切开,有判断词的句子。""" - from app.engine import analysis_claims as said - + """没有报告文档时(成果里的文字):按句末标点切开,有判断词(或者明确说两期不能直接比)的句子。""" out = [] for piece in _SENTENCE_END.split(text or ""): piece = piece.strip() - if piece and said.scan(piece): + if piece and _said_something(piece): out.append({"text": piece, "names": piece, "analyses": []}) return out @@ -459,6 +499,33 @@ def said_in(want: Mapping[str, Any], sentences: Sequence[Mapping[str, Any]], *, return good, bad +def incomparable_spans(text: str) -> list[tuple[int, int]]: + """一句话(写作者自己写的字)里明确说两期不能直接比的几处 [(起, 止)](_INCOMPARABLE)。同一分句里前面有否定的、 + 句子里前面有假设或疑问的不算,问句整句不算。""" + from app.engine import analysis_claims as said + + if text.rstrip("\x00 ").endswith(("?", "?")): + return [] + out = [] + for m in _INCOMPARABLE.finditer(text): + left, _ = said._clause(text, m.start(), m.end()) + if _INCOMPARABLE_DENIED.search(text[left:m.start()]) or _INCOMPARABLE_SUPPOSED.search(text[:m.start()]): + continue + out.append((m.start(), m.end())) + return out + + +def said_incomparable(sentences: Sequence[Mapping[str, Any]]) -> list[dict[str, Any]]: + """正文里明确说两期不能直接比的几处(可比性的期望是不可比时,算说出了它):{text: 所在分句, judgement: incomparable}。""" + out = [] + for raw in sentences: + text = str(raw.get("text") or "") + names = str(raw.get("names") or text) + for start, end in incomparable_spans(text): + out.append({"text": _snippet(names, start, end), "judgement": "incomparable", "subjects": []}) + return out + + def _covered(want: Mapping[str, Any], good: Sequence[Mapping[str, Any]]) -> list[str]: """主要来源期望点名的段里,正文说对的那几处一处都没点到的(top_n 要点名的前几段都要说到)。""" if want.get("judgement") != "source": @@ -492,6 +559,8 @@ def score(exp: Mapping[str, Any], value: Mapping[str, Any], answer: Any) -> dict "reason": f"引用了 {found['alias']},同一句却写成「{bad[0]['text']}」:{bad[0]['reason']}"} return {"outcome": "traced", "want": want, "found": view(found)} good, bad = said_in(value, sentences) + if value.get("judgement") == "incomparable": + good += said_incomparable(sentences) if good: missing = _covered(value, good) if not missing: @@ -517,5 +586,6 @@ def score(exp: Mapping[str, Any], value: Mapping[str, Any], answer: Any) -> dict __all__ = ["ANALYSIS_KINDS", "GOLD_REF", "JUDGEMENTS", "ParamsInvalid", "TOP_N_MAX", "VERDICT_OPS", "check_params", - "expected", "judgement_code", "mismatch", "norm", "op_text", "param_names", "record_of", "request_of", - "said_in", "score", "sentences_of_doc", "sentences_of_text", "steady", "template_texts", "view"] + "expected", "incomparable_spans", "judgement_code", "mismatch", "norm", "op_text", "param_names", + "record_of", "request_of", "said_in", "said_incomparable", "score", "sentences_of_doc", "sentences_of_text", + "steady", "template_texts", "view"] diff --git a/backend/tests/test_eval_analysis.py b/backend/tests/test_eval_analysis.py index edab6cd7..4168f8c1 100644 --- a/backend/tests/test_eval_analysis.py +++ b/backend/tests/test_eval_analysis.py @@ -7,6 +7,8 @@ tests/test_eval_analysis_runs.py。地区、门店、数字都是虚构的。 - 期望值:判定码归到判定类,主要来源按 top_n 取前几段;比较作答前后两次期望时不看运行当天、现查时刻、输入快照的工件。 - 评分五种结论:引用了判定对得上的结论 → 对且可追溯;正文按说法表写对了、没引用 → 对但没引用;引用的判定对不上、正文 写反了(常规波动说成异常、方向反了、来源说成别的段)、根本没做也没说 → 错;期望算不了 → 不能评分。 +- 可比性不进说法表:期望不可比、正文明确说了两期不能直接比的(评分自己的小词表)算对但没引用;假设、疑问、否定的不算; + 期望可比的只能靠引用。 """ from __future__ import annotations @@ -259,13 +261,38 @@ def test_doing_nothing_and_saying_nothing_is_wrong(): uncited = _score(BAND, fake_result("baseline", None, "within_band"), _answer(_record(fake_result("baseline", None, "within_band"), cited=()), text="上周订单数是 1,203 单。")) assert uncited["outcome"] == "wrong" and "但没有引用" in uncited["reason"], uncited - # 可比性没有对应的判断词:只能引用 + + +def test_saying_the_two_periods_cannot_be_compared_directly_is_untraced(): + """可比性不进说法表(说法核对不打回谨慎的提醒),评分另认:期望不可比、正文明确说了两期不能直接比(不完整、还没过完、 + 只有几天、天数不同、口径不同、不能直接比……)的算对但没引用;假设、疑问、否定、「没法比」(差得远)不算;期望可比的 + 正文怎么写都不算,只能靠引用。""" + from app.engine import analysis_claims + exp = {**BAND, "op": "comparable"} - words = _score(exp, fake_result("comparable", None, "incomparable"), _answer(text="本周还没过完,两周不能直接比。")) - assert words["outcome"] == "wrong" - cited = _score(exp, fake_result("comparable", None, "incomparable"), - _answer(_record(fake_result("comparable", None, "incomparable")))) - assert cited["outcome"] == "traced" + incomparable = fake_result("comparable", None, "incomparable") + for text, said in (("本周还没过完,两周不能直接比。", "本周还没过完"), ("本周数据不完整,不能直接比。", "本周数据不完整"), + ("两周的天数不同(7 天 vs 3 天),不宜直接比较。", "两周的天数不同(7 天 vs 3 天)"), + ("统计口径有调整,前后两期不可比。", "统计口径有调整"), + ("这周才过了三天,放在一起看会失真。", "这周才过了三天"), + ("不能直接拿本周和上周比较。", "不能直接拿本周和上周比较")): + item = _score(exp, incomparable, _answer(text=text)) + assert item["outcome"] == "untraced", (text, item) + assert item["found"] == {"judgement": "incomparable", "text": said}, item + assert analysis_claims.scan(text) == [], "说法表照旧不认这类说法:说法核对不会打回" + for text in ("如果口径不同,就不能直接比。", "是不是不能直接比?", "不是不能比,只是要按日均看。", "这周跟上周没法比,差远了。", + "两周都过完了,可以直接比。", "订单信息不完整的有 3 单。"): + item = _score(exp, incomparable, _answer(text=text)) + assert item["outcome"] == "wrong" and "没有做可比性检查" in item["reason"], (text, item) + # 只有说了这类话的句子留下来(和说法表认出判断词的句子一起) + assert [x["text"] for x in judgements.sentences_of_text("上周订单数是 1,203 单。本周还没过完,两周不能直接比。")] == \ + ["本周还没过完,两周不能直接比。"] + # 期望可比:正文怎么写都不算,只能靠引用 + comparable = fake_result("comparable", None, "comparable") + for text in ("两周都是完整的 7 天,可以直接比。", "本周还没过完,两周不能直接比。"): + assert _score(exp, comparable, _answer(text=text))["outcome"] == "wrong", text + assert _score(exp, comparable, _answer(_record(comparable)))["outcome"] == "traced" + assert _score(exp, incomparable, _answer(_record(incomparable)))["outcome"] == "traced" def test_an_analysis_that_cannot_be_computed_cannot_be_graded():