|
15 | 15 | from permea_explain import GuardrailViolation, guardrails, narrate, source |
16 | 16 | from permea_explain.extract import extract |
17 | 17 | from permea_explain.providers.base import Provider, ProviderResponse |
| 18 | +from permea_explain.violations import NUMERIC_UNTRACED |
18 | 19 |
|
19 | 20 | GOOD_IDENTITY = {"alignment": "global", "denominator": "shorter_sequence", "gap_treatment": "free"} |
20 | 21 |
|
@@ -307,6 +308,71 @@ def test_narrate_end_to_end_accepts_grouped_counts(tmp_path): |
307 | 308 | assert result["numeric_provenance"]["all_numbers_traced"] is True |
308 | 309 |
|
309 | 310 |
|
| 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 | + |
310 | 376 | def test_extract_is_available_alongside_narrate(): |
311 | 377 | """Both task layers share one Provider and one verification discipline (#0023).""" |
312 | 378 | import inspect |
|
0 commit comments