Skip to content

Commit c5adf68

Browse files
fix(explain): stop honest narrations from false-failing the number-provenance gate (#80)
* test(ui): stop drylab narration test from making a live API call when creds are set test_only_ok_status_ever_carries_text passed provider=None, which selects the configured provider via get_provider(). On a machine with PERMEA_LLM_TOKEN, PERMEA_LLM_ENDPOINT_ID and PERMEA_LLM_BASE_URL exported, running pytest would issue a real billed request. Clear the three vars first, matching test_missing_credentials_yields_unavailable, so the branch deterministically takes the unavailable path. Assertions unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(explain): remove numeric literals from active warning descriptions so honest narrations stop tripping the number-provenance guard; lock with an invariant test (guardrails.py unchanged) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(explain): blank digit-first identifiers so a faithfully quoted data_sha256 stops false-failing the number-provenance gate The identifier strip required an alnum run to START on a letter/underscore ([A-Za-z_][A-Za-z0-9_]*), so it removed trailing digits ("abc42" whole) but not leading ones ("42abc" left "42"). A sha256 that begins with hex digits -- e.g. 41dba61b...8004d, which the demo fixture (_SEED = 20260721) emits -- left its leading "41" behind as a bare number. collect_numbers deliberately does not read numbers from string leaves, so that "41" traced to nothing and the gate withheld a narration that faithfully quoted the hash (~1 in 4 live K-EXAONE runs, Survey #29). Blank any [A-Za-z0-9_] run WHOLE whenever it carries a letter or underscore, so digits go whether they lead or trail. Pure-digit runs carry no letter and still reach the number tokenizer unchanged. Only the identifier-blanking step changes: the 3-gate composition, source.collect_numbers, _NUM_TOKEN, _THOUSANDS_GROUPED, _strip_thousands_separators, and _forbidden_violations are untouched -- no value-based allowlist, no re-prompting, no gate relaxation. Known limitation, stated plainly in the tests: a fabricated identifier-shaped token (a hallucinated hash) no longer trips the number gate via its leading digits. That detection was accidental; hash fidelity is out of scope for the number-provenance gate, which guards bare fabricated numbers. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent e5c4754 commit c5adf68

6 files changed

Lines changed: 216 additions & 13 deletions

File tree

permea_explain/guardrails.py

Lines changed: 27 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -45,10 +45,20 @@
4545
# (e.g. delta_point = -0.05) is matched with its sign, not as its absolute value.
4646
_NUM_TOKEN = re.compile(r"-?\d+(?:\.\d+)?")
4747

48-
# Identifier-like tokens (PERMEA-W101, ref_b58ee4, physchem_rf): digits inside them are part
49-
# of an opaque reference, not numeric facts, so strip identifiers before reading standalone
50-
# numbers. "PERMEA-W101" -> "PERMEA" and "W101" are both stripped, so 101 is never read.
51-
_IDENTIFIER_TOKEN = re.compile(r"[A-Za-z_][A-Za-z0-9_]*")
48+
# Identifier-like tokens (PERMEA-W101, ref_b58ee4, physchem_rf, the hex of a data_sha256):
49+
# digits inside them are part of an opaque reference, not numeric facts, so strip identifiers
50+
# before reading standalone numbers. A maximal run of [A-Za-z0-9_] is blanked WHOLE whenever
51+
# it carries a letter or underscore, so its digits go whether they LEAD or TRAIL the letters:
52+
# "PERMEA-W101" -> "PERMEA" and "W101" both vanish (101 never read), and a digit-first hash
53+
# "41dba61b...8004d" vanishes entire (the leading 41 never read). A pure-digit run carries no
54+
# letter and is left for the number tokenizer, unchanged.
55+
#
56+
# The earlier form, [A-Za-z_][A-Za-z0-9_]*, required the run to START on a letter/underscore.
57+
# That blanked trailing digits ("abc42" whole) but not leading ones ("42abc" left "42"), so a
58+
# sha256 that began with hex digits left those digits behind as a bare, untraceable number and
59+
# false-failed a narration that faithfully quoted the hash. Blanking is now symmetric.
60+
_ALNUM_RUN = re.compile(r"[A-Za-z0-9_]+")
61+
_HAS_LETTER_OR_UNDERSCORE = re.compile(r"[A-Za-z_]")
5262

5363
_WCODE = re.compile(r"PERMEA-W[0-9]{3}")
5464

@@ -78,14 +88,26 @@ def _strip_thousands_separators(text: str) -> str:
7888
return _THOUSANDS_GROUPED.sub(lambda m: m.group(1).replace(",", ""), text)
7989

8090

91+
def _blank_identifiers(text: str) -> str:
92+
"""Blank every [A-Za-z0-9_] run that carries a letter or underscore, whole.
93+
94+
A pure-digit run denotes a number and is left in place; a run that also holds a letter or
95+
underscore is an opaque identifier (a warning code, a column name, a hash) and is removed
96+
entirely -- leading digits included, so a digit-first hash leaves nothing behind."""
97+
return _ALNUM_RUN.sub(
98+
lambda m: " " if _HAS_LETTER_OR_UNDERSCORE.search(m.group(0)) else m.group(0),
99+
text,
100+
)
101+
102+
81103
def _standalone_number_tokens(text: str) -> list[str]:
82104
"""Numeric tokens that stand alone -- NOT digit runs inside an identifier.
83105
84106
Grouping commas are removed first, so a group-formatted count is assembled into the one
85107
number it denotes. This changes only how a token is ASSEMBLED from the prose; what counts
86108
as a match against the report is unchanged.
87109
"""
88-
without_ids = _IDENTIFIER_TOKEN.sub(" ", _strip_thousands_separators(text))
110+
without_ids = _blank_identifiers(_strip_thousands_separators(text))
89111
return _NUM_TOKEN.findall(without_ids)
90112

91113

permea_explain/prompt.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,11 @@
99
the ``permea_core`` warning registry (a one-way import) so the model can explain what a code
1010
means. The numeric allowed-set is taken strictly from the report's typed fields, never from
1111
registry prose -- and only ACTIVE codes fire, whose descriptions carry no numeric literals.
12+
That last clause was an unchecked assertion and was false in practice (W001/W002 carried
13+
"permea-eval/1.0", W101 "Paper 1 P1", W501 a hardcoded "95%"), so faithful narrations copied
14+
numbers no report field could back and the provenance guard withheld them. The literals are
15+
gone and ``tests/test_explain_registry_numerals.py`` now enforces the invariant against the
16+
guardrail's own extractor.
1217
"""
1318
from __future__ import annotations
1419

@@ -30,7 +35,9 @@
3035
값과 문자 그대로 일치해야 합니다. 리포맷 금지 — 0.0432 를 "4.32%" 나 "4%" 로
3136
바꾸지 말고 그대로 쓰십시오. JSON 의 숫자 필드에 없는 숫자는 출력에 나올 수
3237
없습니다. 신뢰수준(예: "95% 신뢰구간"의 95)처럼 숫자 필드가 아니라 설명 문자열
33-
안에만 있는 값은 숫자로 다시 쓰지 마십시오.
38+
안에만 있는 값은 숫자로 다시 쓰지 마십시오. 아래의 레지스트리 설명(경고 코드의
39+
의미)도 숫자의 출처가 아닙니다. 그 안에 숫자·버전·논문 참조가 보이더라도 출력에
40+
옮기지 마십시오.
3441
3542
2. fired 목록에 있는 경고만 서술하십시오. fired 에 없는 경고 코드(PERMEA-W###)를
3643
지어내지 마십시오.

src/permea_core/contracts/warnings.py

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -84,7 +84,7 @@ class WarningCode:
8484
fire_condition="EvalRun.identity_definition is None (or a built-in default was used, not an explicit declaration)",
8585
description=(
8686
"The identity definition governing the clustering was not explicitly declared. "
87-
"Under permea-eval/1.0 it is required with no default: the entire identity-control "
87+
"Under the permea-eval contract it is required with no default: the entire identity-control "
8888
"result depends on how identity is defined (alignment, denominator, gap treatment), "
8989
"so an undeclared or silently-defaulted definition makes the headline number "
9090
"uninterpretable and non-comparable across runs."
@@ -99,8 +99,8 @@ class WarningCode:
9999
severity=Severity.CRITICAL,
100100
fire_condition='EvalRun.headline_condition != "B"',
101101
description=(
102-
"The reported headline is not the identity-controlled condition B. permea-eval/1.0 "
103-
"fixes the headline to the identity-controlled evaluation; reporting the uncontrolled "
102+
"The reported headline is not the identity-controlled condition B. The permea-eval "
103+
"contract fixes the headline to the identity-controlled evaluation; reporting the uncontrolled "
104104
"condition A (or anything else) as the headline re-admits exactly the similarity "
105105
"leakage the protocol exists to remove."
106106
),
@@ -132,7 +132,8 @@ class WarningCode:
132132
description=(
133133
"Identity-controlled evaluation (B) scores significantly below the uncontrolled "
134134
"evaluation (A), with the paired-bootstrap CI on the delta excluding zero. This is "
135-
"the core Paper 1 P1 finding: a material fraction of the uncontrolled performance was "
135+
"the core finding the identity-control protocol exists to expose: a material fraction "
136+
"of the uncontrolled performance was "
136137
"similarity memorisation of near-duplicate train/test peptides, not generalisation. "
137138
"The magnitude that counts as 'material' is a diagnose-layer policy, not fixed here."
138139
),
@@ -148,7 +149,7 @@ class WarningCode:
148149
severity=Severity.WARN,
149150
fire_condition="a reported delta has ci_excludes_zero == False (CI straddles zero)",
150151
description=(
151-
"The paired-bootstrap 95% CI on the A-vs-B delta includes zero, so the two conditions "
152+
"The paired-bootstrap CI on the A-vs-B delta includes zero, so the two conditions "
152153
"are not statistically distinguishable at that level. The point estimate should not be "
153154
"reported as a real effect; the result is inconclusive rather than null."
154155
),

tests/test_explain_narrate.py

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
from permea_explain import GuardrailViolation, guardrails, narrate, source
1616
from permea_explain.extract import extract
1717
from permea_explain.providers.base import Provider, ProviderResponse
18+
from permea_explain.violations import NUMERIC_UNTRACED
1819

1920
GOOD_IDENTITY = {"alignment": "global", "denominator": "shorter_sequence", "gap_treatment": "free"}
2021

@@ -307,6 +308,71 @@ def test_narrate_end_to_end_accepts_grouped_counts(tmp_path):
307308
assert result["numeric_provenance"]["all_numbers_traced"] is True
308309

309310

311+
# --------------------------------------------------------------------------------------
312+
# Symmetric identifier blanking (#0030)
313+
# --------------------------------------------------------------------------------------
314+
# The identifier strip once required a run to START on a letter/underscore, so a sha256 that
315+
# began with hex digits ("41dba61b...8004d") left its leading "41" behind as a bare number and
316+
# false-failed a narration that faithfully quoted the hash. Blanking now removes any
317+
# [A-Za-z0-9_] run carrying a letter/underscore WHOLE -- leading digits included -- while a
318+
# pure-digit run still reaches the number gate.
319+
#
320+
# KNOWN LIMITATION, stated plainly: after this change a FABRICATED identifier-shaped token (a
321+
# hallucinated hash, a made-up column name with digits) no longer trips the provenance gate
322+
# via its leading digits. That detection was ACCIDENTAL, never a guarantee -- the number gate
323+
# exists to catch bare fabricated NUMBERS, not fabricated identifiers, and hash fidelity is out
324+
# of scope for it. The tests below pin the intended behavior, not that lost side effect.
325+
326+
# The demo fixture (permea_ui.fixtures, _SEED = 20260721) emits this exact digit-first sha256;
327+
# see tests/test_ui_drylab.py for the end-to-end regression over that fixture.
328+
_DIGIT_FIRST_SHA256 = "41dba61bf87bd33674c49388075707f5a0dade7060a2da6298bd7f91b1b8004d"
329+
330+
331+
def test_digit_first_identifier_leaves_no_bare_number():
332+
"""A digit-first hash is blanked whole; trailing-digit ids already were -- now symmetric."""
333+
assert guardrails.numbers_in_narrative(f"데이터 해시는 {_DIGIT_FIRST_SHA256} 입니다.") == []
334+
# "42abc" (leading digits) now matches "abc42" (trailing): both blank whole, neither leaks.
335+
assert guardrails.numbers_in_narrative("식별자 42abc 와 abc42 를 봅니다.") == []
336+
337+
338+
def test_faithful_hash_quote_passes_provenance(tmp_path):
339+
"""(a) Quoting the report's data_sha256 no longer trips the number-provenance gate."""
340+
_, report = _report_file(tmp_path)
341+
report["context"]["data_sha256"] = _DIGIT_FIRST_SHA256 # digit-first, as the demo fixture emits
342+
text = f"이 해설은 데이터 해시 {_DIGIT_FIRST_SHA256} 를 참조합니다. 조건 B 가 헤드라인입니다."
343+
assert guardrails.render_checked(text, report) == text
344+
345+
346+
def test_fabricated_standalone_number_still_untraced(tmp_path):
347+
"""(b) LOAD-BEARING: a bare fabricated number absent from the report is STILL rejected.
348+
349+
Proves symmetric blanking did not weaken the gate: only alnum runs CARRYING a letter are
350+
swallowed; a standalone "0.87" carries none, reaches the gate, and fails as NUMERIC_UNTRACED.
351+
"""
352+
_, report = _report_file(tmp_path)
353+
violations = guardrails.collect_violations(FAITHFUL + " 정확도는 0.87 입니다.", report)
354+
assert [v.kind for v in violations] == [NUMERIC_UNTRACED]
355+
assert "0.87" in violations[0].message
356+
357+
358+
def test_korean_adjacent_digits_still_reach_the_gate(tmp_path):
359+
"""(c) Hangul is outside [A-Za-z0-9_], so "180개" still yields "180" and is provenance-checked."""
360+
assert guardrails.numbers_in_narrative("표본은 180개입니다.") == ["180"]
361+
_, report = _report_file(tmp_path) # this report has n=2959, not 180
362+
with pytest.raises(GuardrailViolation, match="180"):
363+
guardrails.render_checked("표본은 180개입니다.", report)
364+
365+
366+
def test_pure_digit_and_decimal_tokenization_unchanged():
367+
"""(d) Pure-digit, decimal, and thousands-grouped forms tokenize exactly as before."""
368+
assert guardrails.numbers_in_narrative("총 1000 개, 95%, 임계값 0.02, 델타 -0.4344") == [
369+
"1000", "95", "0.02", "-0.4344",
370+
]
371+
assert guardrails.numbers_in_narrative("표본은 2,959개") == ["2959"]
372+
# A warning code still loses its digits (W101 -> gone); the standalone 0.87 survives.
373+
assert guardrails.numbers_in_narrative("PERMEA-W101 이 발화, 정확도 0.87") == ["0.87"]
374+
375+
310376
def test_extract_is_available_alongside_narrate():
311377
"""Both task layers share one Provider and one verification discipline (#0023)."""
312378
import inspect
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
"""Cross-layer invariant: no ACTIVE warning description may carry a numeric literal.
2+
3+
WHY THIS EXISTS. ``permea_explain.prompt._registry_context`` injects the title and
4+
description of every FIRED code into the NARRATE prompt as non-numeric context, and
5+
``prompt``'s module docstring asserts that the numeric allowed-set comes strictly from the
6+
report's typed fields, "never from registry prose". That assertion was FALSE in practice:
7+
W001/W002 carried "permea-eval/1.0", W101 carried "Paper 1 P1", and W501 hardcoded a "95%"
8+
CI level. A faithful narration that translated the context it was handed therefore emitted
9+
numbers with no report field behind them, and the number-provenance guard correctly -- but
10+
uselessly -- withheld it. Two live runs were lost to exactly this.
11+
12+
The fix removed the literals; this test keeps them removed. It is deliberately a test and
13+
not a guardrail relaxation: the guard stays strict, and we remove the CAUSE instead.
14+
15+
It reuses ``guardrails._standalone_number_tokens`` rather than reimplementing a number
16+
regex, so the invariant is stated in terms of the SAME extractor that will judge the
17+
narration at runtime. If that tokenizer changes, this test tracks it automatically and
18+
cannot drift into checking something subtly different.
19+
20+
RESERVED codes are exempt: they have no firing logic, so they can never reach a prompt.
21+
W201/W301 ("Paper 1 P2"/"P3") and W403 ("n_jobs=-1", "~1e-7") still carry literals and must
22+
be sanitised before any of them is promoted to ACTIVE -- ``test_reserved_codes_are_the_only
23+
_exemption`` pins that list so a promotion cannot silently skip the cleanup.
24+
"""
25+
26+
from __future__ import annotations
27+
28+
import pytest
29+
30+
from permea_core.contracts.warnings import WARNINGS, Status, active
31+
from permea_explain.guardrails import _standalone_number_tokens
32+
33+
#: RESERVED codes known to still carry numerals. Not a permanent exemption -- an entry here
34+
#: is a debt to pay at promotion time, which is why the set is pinned rather than ignored.
35+
KNOWN_NUMERIC_RESERVED = {"PERMEA-W201", "PERMEA-W301", "PERMEA-W403"}
36+
37+
38+
@pytest.mark.parametrize("warning", active(), ids=lambda w: w.code)
39+
def test_active_description_has_no_numeric_literal(warning):
40+
"""An ACTIVE code's prompt-visible prose must contribute no numeric token.
41+
42+
``title + description`` is exactly what ``_registry_context`` puts in the prompt, so a
43+
token found here is a token the model may faithfully copy into a narration that then
44+
fails the provenance gate for a number no report field can back.
45+
"""
46+
prompt_visible = f"{warning.title} {warning.description}"
47+
tokens = _standalone_number_tokens(prompt_visible)
48+
assert tokens == [], (
49+
f"{warning.code} is ACTIVE and its prompt-visible prose yields numeric token(s) "
50+
f"{tokens}. A narration that copies one cannot trace it to any report field, so the "
51+
f"number-provenance guard will withhold an otherwise honest narration. Rewrite the "
52+
f"prose without the literal (drop the version tag / citation / hardcoded level); do "
53+
f"not relax the guard."
54+
)
55+
56+
57+
def test_reserved_codes_are_the_only_exemption():
58+
"""Pin which RESERVED codes still owe a cleanup, so promoting one cannot skip it."""
59+
offenders = {
60+
w.code
61+
for w in WARNINGS
62+
if w.status is Status.RESERVED
63+
and _standalone_number_tokens(f"{w.title} {w.description}")
64+
}
65+
assert offenders == KNOWN_NUMERIC_RESERVED, (
66+
f"RESERVED codes carrying numerals changed: {offenders}. If a code was promoted to "
67+
f"ACTIVE, sanitise its prose first; if one was cleaned, drop it from "
68+
f"KNOWN_NUMERIC_RESERVED."
69+
)
70+
71+
72+
def test_extractor_actually_bites():
73+
"""Non-vacuity: the assertion must fail on the prose this fix removed.
74+
75+
Without this, a tokenizer that returned [] for everything would make the test above pass
76+
silently. These are the exact literals that cost two live runs.
77+
"""
78+
assert _standalone_number_tokens("Under permea-eval/1.0 it is required") == ["1.0"]
79+
assert _standalone_number_tokens("This is the core Paper 1 P1 finding") == ["1"]
80+
assert _standalone_number_tokens("The paired-bootstrap 95% CI on the delta") == ["95"]

tests/test_ui_drylab.py

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
from fastapi.testclient import TestClient
1818

1919
from permea_core.contracts.warnings import Status, WARNINGS
20+
from permea_explain import guardrails
2021
from permea_explain.providers.base import ProviderResponse
2122
from permea_ui import fixtures, runner
2223
from permea_ui.app import app
@@ -98,6 +99,24 @@ def test_guardrail_failure_yields_withheld_and_no_text(diagnosis):
9899
assert "offending output" not in (n.detail or ""), "detail must not leak the bad prose"
99100

100101

102+
def test_narration_quoting_the_data_sha256_is_not_withheld(diagnosis):
103+
"""A narration that faithfully quotes the report's data_sha256 must render, not be withheld.
104+
105+
Regression for #0030 over the demo fixture (fixtures._SEED = 20260721), whose sha256 is
106+
digit-first -- 41dba61b...8004d. The old identifier strip left that leading "41" behind as
107+
a bare, untraceable number, so the number-provenance gate withheld the narration ~1 in 4
108+
live runs. Symmetric blanking removes the hash whole. No live API: the hash is the only
109+
numeric-looking content and a FakeProvider supplies the prose.
110+
"""
111+
sha = diagnosis["context"]["data_sha256"]
112+
text = f"이 진단의 데이터 해시는 {sha} 입니다."
113+
# The crux: the quoted hash contributes no standalone numeric token for the gate to check.
114+
assert guardrails.numbers_in_narrative(text) == []
115+
n = runner.narrate_diagnosis(diagnosis, provider=FakeProvider(text))
116+
assert n.status == "ok", f"expected ok, got {n.status}: {n.detail}"
117+
assert n.text == text
118+
119+
101120
def test_missing_credentials_yields_unavailable(diagnosis, monkeypatch):
102121
for var in ("PERMEA_LLM_TOKEN", "PERMEA_LLM_ENDPOINT_ID", "PERMEA_LLM_BASE_URL"):
103122
monkeypatch.delenv(var, raising=False)
@@ -119,8 +138,16 @@ def complete(self, *a, **kw):
119138
assert "TimeoutError" in (n.detail or "")
120139

121140

122-
def test_only_ok_status_ever_carries_text(diagnosis):
123-
"""Whatever the status, text is non-None only for `ok`. The UI keys off this."""
141+
def test_only_ok_status_ever_carries_text(diagnosis, monkeypatch):
142+
"""Whatever the status, text is non-None only for `ok`. The UI keys off this.
143+
144+
The `None` case must stay offline: with the three vars set it would select the
145+
configured provider and bill a real call from the test suite. Cleared here so the
146+
branch deterministically takes the `unavailable` path, as at
147+
test_missing_credentials_yields_unavailable.
148+
"""
149+
for var in ("PERMEA_LLM_TOKEN", "PERMEA_LLM_ENDPOINT_ID", "PERMEA_LLM_BASE_URL"):
150+
monkeypatch.delenv(var, raising=False)
124151
for provider in (FakeProvider("정확도는 99.7% 입니다."), None):
125152
n = runner.narrate_diagnosis(diagnosis, provider=provider)
126153
assert (n.text is not None) == (n.status == "ok")

0 commit comments

Comments
 (0)