fix: 타입 리터럴 파서가 전각 숫자를 거부한다 - #336
Conversation
literal_types 의 아홉 정규식이 모두 `\d` 로 숫자를 인식했다. 파이썬의 `\d` 는 유니코드 Nd 범주 전체에 매치하므로 `100억` 이 `100억` 과 **같은 스칼라**로 파싱되면서 relation/3 에 저장되는 객체 문자열은 서로 달랐다. 그래서 "같은 값인가" 에 대한 답이 코드 경로마다 갈렸다 — typed 관계 아래서는 병합되고, 객체 매치 질의에서는 조용히 미스하고, typed 선언이 없으면 별개 엔티티로 남았다. 수치 그룹 21곳을 정규식 단위로 다시 써 `[0-9]` 로 좁힌다. 전각 값은 "파싱 실패 → 타입 없이 적재" 라는 기존 계약을 그대로 타고, _project_typed_relations 의 경고로 표면화된다. 접기(fold)를 택하지 않은 이유는 그것이 저장된 사실 문자열을 다시 쓰는 결정이기 때문이다. 거부가 접기보다 일관된 근거가 하나 더 있다. cli.py 의 status 경로는 이미 원문 객체로 conflict 를 세는데 check_conflicts.py 만 스칼라로 병합해 0 을 보고한다. 두 경로가 현재 서로 모순이며, 전각을 거부하면 그 불일치가 해소된다. re.ASCII 는 쓰지 않는다. 같은 플래그가 `\s` 도 좁혀 `date(2020,<U+3000>1)` 이 조용히 깨진다. `\D+` 단위 그룹은 그대로 둔다 — `\D` 는 Nd 의 여집합이라 이미 전각을 배제한다. 모듈 밖 세 경로의 동작이 함께 바뀐다(의도된 결과, 데이터 재작성 없음). merge_candidates 의 dedup 키가 전각 두 표기를 더는 합치지 않고, 기존 KB 에 저장된 전각 값은 _canonical_value 로 정준화되지 않아 질의가 미스하며, humanize 는 전각 term 을 원문 그대로 돌려준다. 모듈 docstring 에 적어 두었다. 잔여 증상도 함께 적었다. 표면화는 typed 로 **선언된** 관계에서만 일어난다. attribute 선언만 있고 typed 가 아닌 관계의 전각 값은 여전히 어디에도 뜨지 않는다. 업스트림 #331
타입 지정 리터럴은 ASCII 숫자만 파싱한다는 계약과, 전각을 조용히 접지 않는 이유(저장된 사실 문자열을 다시 쓰게 된다)를 ko/en 양쪽에 적는다. 마이그레이션 주의도 함께 적는다. 이 규칙 이전에 수집된 전각 값이 남아 있으면 check_conflicts 가 새로 exit 1 로 실패할 수 있다 — 경고가 아니라 게이트 실패다. 전에는 `100억` 과 `100억` 이 같은 스칼라로 접혀 한 값이었지만 이제 전각 쪽이 원문 문자열로 남아, 같은 주어의 단일값 관계에서 두 값이 되기 때문이다. 해소는 소스를 ASCII 로 고쳐 재수집하는 것이다. 레포에 CHANGELOG 가 없어 이 문서가 유일한 안내 지점이다. 업스트림 #331
_LITERAL_RE 는 냄새 탐지기라 literal_types 의 엄격한 ASCII 전용 계약과 일부러 다르다. 여기서는 `\d` 가 전각까지 잡아야, 파서가 거부한 전각 값이 audit 에는 남아 사람 눈에 띈다. 좁히면 그 값이 어느 경로에서도 안 보이게 된다. 정규식은 그대로 두고 주석만 남긴다. 업스트림 #331
`amount('10억')` 과 `number('123')` 은 홑따옴표를 쓴다. compound 문법에는
홑따옴표가 없으므로 이 둘은 전각과 무관하게 baseline 에서도 이미 None 이었다 —
즉 전각 거부를 증명하지 못하는 vacuous 케이스였다.
baseline 에서 각각 1000000000 / 123000 을 돌려주던 `amount(10,"억")` 과
`number("123")` 로 바꾼다. 이제 두 케이스 모두 이 수정이 없으면 실패한다.
업스트림 #331
모듈 밖 소비자를 전수로 다시 세니 셋이 아니라 넷이다. 빠져 있던
check_conflicts._group_key 가 나머지 셋보다 무거운 경로다 — 나머지는 "데이터
재작성 없음" 이 요점이지만, 이쪽은 통과하던 게이트가 exit 1 로 뒤집힌다. 전각
객체가 ("raw", obj) 로 떨어져 ASCII 쌍둥이의 ("scalar", …) 와 갈리고, 단일값
관계에서 두 값이 되기 때문이다. 사용자 문서에는 적혀 있었지만 코드를 읽는
사람은 놓칠 자리였다.
merge dedup 서술도 결과를 절반만 적고 있었다. 안 합쳐지는 것에 더해, 새로
머지되는 전각 행의 저장 문자열 자체가 따옴표 정준형으로 재작성되지 않고 원문
그대로 남는다(merge_candidates 는 canonical_amount 가 None 이 아닐 때만 객체를
덮어쓴다). 기존 KB 행이 불변이라는 주장은 그대로다.
표면화 경로도 정확히 다시 적었다. 이슈 AC3 이 "경고 또는 audit" 이라 쓴 대로
경로는 둘이다 — typed 선언된 관계는 투영 경고, attribute 로 선언되지 **않은**
관계는 entity_audit 의 literal suspects(_LITERAL_RE 가 일부러 느슨해 전각을
계속 잡는다). 진짜 구멍은 그 사이, attribute 선언은 있고 typed 는 아닌 관계
하나뿐이다. 이전 문구는 이 구멍을 실제보다 넓게 적고 있었다.
업스트림 #331
TestLiteralReConsistency 는 "must not drift" 를 표방하면서 ASCII 8건만 핀했다. 그래서 누가 일관성을 이유로 _LITERAL_RE 를 [0-9] 로 좁혀도 아무 테스트도 깨지지 않는다 — 그런데 그 좁힘은 표면화 경로 하나를 통째로 닫는다. attribute 로 선언되지 않은 관계의 전각 값은 _LITERAL_RE 가 잡아 주어야 literal suspects 로 뜬다. _LITERAL_RE.match 가 True 인 것과 normalize 가 None 인 것을 한 테스트에서 함께 단언해, 느슨함과 엄격함이 **일부러** 다르다는 계약을 테스트가 들고 있게 한다. 뮤턴트 확인: _LITERAL_RE 의 \d 를 [0-9] 로 좁히면 이 4건이 실패하고 나머지는 전부 통과한다(= 이전에는 이 계약을 아무도 붙들지 않았다는 뜻이기도 하다). 업스트림 #331
문서가 경고한 check_conflicts exit 1 은 이번 변경이 사용자 파이프라인을 실제로
깨뜨릴 수 있는 유일한 동작인데, 문서 문장 하나에만 기대고 있었다. 나중에
_group_key 에 전각 폴백이 들어가 조용히 되돌아가도 아무것도 실패하지 않는다.
_group_key('100억') == ("raw", …) 와 ASCII 쌍둥이의 ("scalar", …) 가 갈리는
것, 그래서 단일값 관계에서 두 값이 되어 CONFLICT 가 뜨는 것을 amount·date 로
고정한다. 전각 한 표기만 있는 KB 는 여전히 conflict 가 아니라는 것도 함께 —
raw 로의 강등이 없던 모순을 만들어내면 안 된다.
check_conflicts.py 는 읽기만 했고 수정하지 않았다.
뮤턴트 확인: _group_key 가 normalize 전에 NFKC 로 접게 만들면 이 중 3건이
실패한다(전각 단독 케이스는 폴드 하에서도 초록이라 통과 — 의도대로다).
업스트림 #331
마이그레이션 절에는 check_conflicts exit 1 만 있었다. 그런데 이슈 본문이 문제로
지목한 것은 오히려 조용한 쪽이다 — "검증된 부정(engine says no)과 구분되지
않는다". 시끄러운 실패보다 사용자 대면 문서에 들어갈 자격이 더 있다.
기존 KB 에 전각 amount compound term 이 저장돼 있으면 따옴표 없이 쓴 질의가 이제
조용히 미스한다. 전각 항이 더는 유효한 amount 가 아니라서 저장본과 같은 정준형으로
접히지 않기 때문이다. 실측:
before: canon('amount(100,억)') = 'amount(100,"억")' -> 저장본과 match
after : canon('amount(100,억)') = 'amount(100,억)' -> match 안 됨
해소는 두 경우 모두 동일해서 마지막 문장을 공유하도록 문단을 다시 묶었다.
ko/en 같은 커밋.
humanize 불릿만 소비자 파일명을 안 밝히고 있어 나머지 셋과 통일했다
(tools/ask_router.py — pretty 가 원문과 같아지므로 "(= …)" 접미가 붙지 않는다).
업스트림 #331
정규식 9개를 하나씩 \d 로 되돌려 스위트를 돌리면 _ORDINAL_EN_RE 만 뮤턴트가 살아남았다. 동작 자체는 고쳐져 있었지만(HEAD 에서 '3rd' -> None, baseline 에서는 3) 그것을 고정하는 테스트가 없었다. 신규 ordinal 커버리지가 ordinal(3)·3위· 제3호 뿐이고 영문 서수형이 빠져 있었던 탓이다. 미래 리팩터가 이 한 줄을 조용히 되돌려도 아무것도 안 깨지는 상태였다. 전각 '3rd' 와 혼합폭 '12th' 을 각각 거부 목록에 넣는다. 업스트림 #331
justinjoy
left a comment
There was a problem hiding this comment.
결론: CHANGE REQUEST
수정 자체는 흠잡을 데가 없다. 본문의 검증 주장 7건을 전부 독립 재현했고 전부 참이다. 오히려 일부는 주장보다 강하다.
| 주장 | 결과 |
|---|---|
| 706 passed / 13 skipped (baseline 655/13, 신규 51건) | 확인 — 719 passed(PR) vs 668 passed(main). 이 환경은 skip 0 이지만 총계가 정확히 일치 |
| 뮤테이션 9/9 사살, 생존 0 | 확인 — 정규식 9개를 각각 수정 전 철자로 완전 복원해 9/9 사살(각 1~7건 실패) |
| 차등 퍼징 40,000+ 건, 차이 0, 누수 0 | 확인, 초과 달성 — 40,017 입력 × 진입점 11개 = 440,187 비교, 차이 0, 누수 0 |
re.ASCII 가 date(2020,<U+3000>1) 을 깬다 |
실측 확인 — re.I → 매치, re.I|re.ASCII → 무매치, [0-9] → 매치 |
\D 단위 그룹 무변경으로 충분 |
확인 — \D 가 Nd 여집합이라 123억, 1001억 전부 실패 |
| tests/*.sh main 과 동일 | 확인 — 41개 파일 바이트 동일, 39/39 통과(환경 실패 0건, 주장보다 나음) |
| golden MATCH | 확인 — tests/golden.sh 가 main·PR head 양쪽에서 5/5 |
정규식은 9개가 맞고 실행 코드에 \d 가 남아 있지 않으며, \D/\s 를 건드리지 않은 것도 옳다. \w 는 이 모듈에 아예 없다. 우회 경로도 없다 — 아랍-인도 ١٢٣, 데바나가리 ०१२, 태국어 ๑๒๓ 전부 거부된다.
특히 int/float/Decimal 이 유니코드 숫자를 조용히 받아들인다는 것(Decimal('123')==123, int('123')==123)이 이 결함의 진짜 위험인데, 이 PR 이 정규식을 유일하고 올바른 게이트로 만들어 모든 리터럴 파싱 경로에서 그것을 막는다. 정책 (A) 거부도 옳다 — 모듈 계약(literal_types.py:12 "never guesses")과 사람 게이트 의미에 부합하고, cli.py:1762-1768 status 경로가 원문 문자열 set 으로 세어 이미 2값이었다는 주장도 사실이라 이 PR 이 check_conflicts 를 status 쪽으로 수렴시킨다. entity_audit._LITERAL_RE 를 일부러 안 좁힌 판단도 옳고, test_literal_types.py:387-398 이 그 발산을 함께 묶어 고정한 것도 적절하다.
8eb9e0a 커밋이 저자가 이 뮤테이션을 직접 돌려 _ORDINAL_EN_RE 단독 생존을 찾아내고 "동작 자체는 고쳐져 있었지만 그것을 고정하는 테스트가 없었다" 로 정확히 진단한 뒤 3rd/12th 를 추가한 기록이다. 9/9 는 주장이 아니라 그 구멍을 닫은 결과다.
#335 와는 충돌 없다. 파일 겹침 0, 시험 머지 clean, 병합 트리 755 passed.
그럼에도 CHANGE REQUEST 인 이유는 "조용히 접지 않고 시끄럽게 거부한다" 는 이 PR 의 핵심 논거가 실패 지점에서 완성되지 않기 때문이다. 두 건 다 텍스트 수준 수정이다.
Blocker
1. 실패 시 출력되는 해소 안내가 이 케이스에 대해 틀렸고, 그대로 따르면 KB 가 조용히 망가진다.
tools/check_conflicts.py:215 과 tools/finalize.py:250-261 이 내놓는 유일한 안내:
Resolve by marking the outdated row(s) status='superseded' in facts/candidates.csv, then re-run.
두 행 중 어느 쪽도 outdated 가 아니다 — 하나의 값이 두 표기로 적힌 것이다. 안내를 따라 ASCII 행을 supersede 하면 실측으로 이렇게 된다:
check_conflicts: 0 conflicts across 1 single-valued relation(s) rc=0
relation("갑사", "매출", "100억"). # revenue_amt 로 영영 projection 되지 않음
게이트는 초록이 되고, 정상 사실 하나는 버려지고, 파싱 불가 값은 남는다. 이 PR 의 문서가 지시하는 해소("소스를 ASCII 로 고쳐 재수집")와 정반대다. repr() 도 전각을 이스케이프하지 않으므로(repr('100억') → '100억') 폰트에 따라 123억 vs 123억 같은 혼용은 육안 구분이 사실상 불가능하다.
완화 요소는 있다 — 후속 run_logic_check 경고가 문제 값을 지목하고(typed-relations: '100억' for '매출' ... does not parse as amount), supersede 는 비파괴적이며, 두 값이 나란히 출력된다. 그래서 이 항목의 심각도는 판단이 갈렸다. 막기로 한 이유는, 사용자가 도구가 스스로 인쇄한 지시를 따랐을 때 더 나쁜 상태에 도달하는 형태이고, 이 PR 이 새로 만들어내는 실패 부류에 대해서만 그렇기 때문이다. 게이트를 뒤집는 것이 이 PR 의 유일한 사용자 가시 효과인데 그 순간의 안내가 틀렸다면 논거가 사용자 앞에서 성립하지 않는다.
check_conflicts.py:205-218 의 출력 루프에서, 한 conflict 그룹의 값 중 비-ASCII Nd 를 포함한 것이 있으면 조건부로 한 줄 덧붙이면 된다:
note: '100억' 은 비-ASCII 숫자를 포함한다 — supersede 로는 해소되지 않는다.
소스를 ASCII 숫자로 고쳐 재수집하라 (docs/reference/typed-relations.md).
factlog/cli.py:1768(⚠ resolve via superseded / see tools/check_conflicts.py)도 같은 문제이고 값조차 보여주지 않는다.
2. 마이그레이션 문단이 실제 영향을 한 단계 낮게 적었다.
docs/reference/typed-relations.md:56-59(+.en.md:62-65)는 "exit 1 로 실패할 수 있습니다 — 경고가 아니라 게이트 실패입니다" 까지만 쓴다. 그런데 tools/finalize.py:246 은:
accepted_dl.unlink(missing_ok=True)전각 값이 단일값 typed 관계에 있는 기존 KB 는 다음 finalize 에서 facts/accepted.dl 이 삭제되어 /factlog ask 가 아무것도 반환하지 않는 상태가 된다. finalize.py:252-256 은 이미 그렇게 말하고 있다. 사용자가 실제로 겪는 것은 "게이트 실패" 가 아니라 "KB 가 답을 못 함" 이다.
저장소에 CHANGELOG 가 없고 릴리스가 release: X.Y.Z 커밋뿐이라 이 문단이 유일한 마이그레이션 채널이라는 PR 의 전제는 관행상 맞다. 그렇기 때문에 정확해야 한다.
Should fix
-
literal_types.py:54-60의 "two user-visible paths" 가 과장이다. 경로 2(entity_audit)는 파이프라인에서 호출되지 않는다 —grep -rn entity_audit의 호출부는SKILL.md:243의 수동 실행 안내뿐이다. 출력 문구(entity_audit.py:163)도→ consider adding '<rel>' to policy/attribute-relations.md로 숫자 폭에 대해 아무 말도 안 한다. 게다가 bare prose 형태에만 성립한다:값 표면화 100억,2026,제3호,2030.1,3위,123억AUDIT 3rd없음 date(2020,1),amount(100,"억"),number(123),ordinal(3)없음 회귀는 아니다(ASCII
3rd/date(2020,1)도_LITERAL_RE에 안 걸린다). 다만test_full_width_divergence_is_intended가 작동하는 4개 형태만 파라미터로 잡아서 계약 전체를 덮는 것처럼 읽힌다. 정확한 서술은 "자동 경고 하나(typed projection) + 게이트 하나 + 수동 감사 도구 하나 + 구멍 하나" 다.3rd를 "표면화되지 않음" 케이스로 테스트에 추가해달라. -
비-ASCII
Nd거부를 테스트로 고정하라. 문서 서술은 정확하다(typed-relations.md:48"숫자는 ASCII 숫자만",.en.md:53"Digits must be ASCII",literal_types.py:21"Digits are ASCII-only") — 일반 규칙으로 옳게 썼다. 그런데TestFullWidthDigitsRejected(test_literal_types.py:402-470)는 전부 전각(U+FF10–FF19)만 다룬다. 누군가 나중에[^0-9]류로 "전각만" 거부하게 바꿔도 전건 통과한다.("number","١٠٠"),("amount","١٠٠억"),("number","१२३")를 추가하면 계약이 실제로 잠긴다. 문서 예시도 전부 전각이니 "그 밖의 유니코드 숫자 체계도 마찬가지" 한 절을 넣어달라. PR 제목도 실제 범위보다 좁게 읽힌다. -
cli.py:1762-1768status 수렴을 docstring call-site 목록(literal_types.py:30-52)에 추가하라. (A) 를 정당화하는 가장 강한 코드 증거인데 네 call site 목록에서 빠져 있다. -
re.ASCII배제 근거를 한 줄 더. "전각 숫자는 거부, 전각 공백은 수용" 은 원칙적으로 옳다 — 공백은 값의 일부가 아니고 숫자는 값이다. 그런데 이 논거가 어디에도 없고:91-95의 "don't use re.ASCII" 주석만 있어서 나중에 임의 결정으로 읽힐 위험이 있다.
Nit
_AMOUNT_COMPOUND_RE의 단위 그룹은 여전히 전각을 받는다(:126-130).canonical_amount('amount(100,100)')→'amount(100,"100")',humanize는100100로 렌더한다. main 과 바이트 동일한 선행 동작이고(단위 표에 없어 정규화되지 않는다) 범위 밖이지만, docstring 이 "아래 모든 수치 그룹은[0-9]로 적혔다" 고 하므로 단위 그룹이 의도적으로 숫자 정책 밖이라는 한 줄이 있으면 다음 읽는 사람이 누락으로 오해하지 않는다.
후속 이슈로 분리 권장
증상 (2) 의 조용한 질의 미스는 문서 문단으로만 두지 말고 이슈로 남겨달라. 실측으로 회귀임이 확인된다:
amount(100,억) 질의 vs amount(100,"억") 저장 |
|
|---|---|
main c6d359d |
canonical → amount(100,"억") — MATCH |
| pr/336 | canonical → None → raw — MISS |
NFC 는 전각을 접지 않으므로(NFKC 만 접고, 저장소에 NFKC 사용처가 없다) _canonical_value(common.py:1743)가 구제해주지 않는다. 영향 범위는 좁지만(전각으로 일관되게 작성된 기존 KB), 이 PR 에서 시끄러운 실패를 조용한 실패와 맞바꾼 유일한 지점이다. "silent fold 를 거부한다" 는 논거로 silent miss 를 도입하는 셈이다. 최소 완화는 질의 리터럴에 비-ASCII 숫자가 있으면 경고하는 것이고, 근본안은 finalize 에 accepted 사실의 비-ASCII 숫자 일회성 스캔을 넣는 것이다 — 그러면 이 미스와 "attribute 선언 O / typed X" 구멍이 동시에 닫힌다.
Blocker 2건(문서 문단에 accepted.dl 삭제 명시 + 게이트 메시지에 조건부 힌트 한 줄)만 반영되면 머지한다. 정규식 변경 본체, _LITERAL_RE 를 안 좁힌 판단, re.ASCII 배제, 테스트 구조는 그대로 두어도 좋다.
Closes #331.
무엇을 고쳤나
factlog/literal_types.py의 정규식 9개에서 수치 그룹을\d→[0-9]로 좁혀, 전각 숫자가 typed 리터럴로 파싱되지 않게 한다.이슈가 제시한 두 방향 중 (A) 거부를 택했다. (B) 접기(merge 단계에서 저장 문자열을 ASCII 로 폴드)는
tools/merge_candidates.py를 고쳐야 하고, 무엇보다 사람이 accept 한 문자열과 KB 문자열을 다르게 만든다 — 사람 게이트의 의미를 침식한다. 모듈 계약이 "never guesses" 인데 전각 수용은 "이 문자열은 ASCII 와 같은 값" 이라는 추측이다.(A) 를 지지하는 근거가 하나 더 있다.
factlog/cli.py:1762-1764의factlog status경로는 이미 원문 객체로 conflict 를 센다. 반면tools/check_conflicts.py만 스칼라로 병합해 0 을 보고한다 — 두 경로가 현재 서로 모순이고, 전각을 거부하면 그 불일치가 해소된다.이 수정이 남기는 것
이슈의 세 증상 중 (1) typed 스칼라 조용한 병합만 닫는다.
amount(100,"억")가 저장돼 있으면amount(100,억)질의가 이제 미스한다. 정책 (A) 하에서 전각 항은 유효한 amount 가 아니므로 정준화 대상이 없다 — 의도된 결과다.표면화 경로는 둘, 구멍은 하나. typed 선언 관계는
_project_typed_relations가 stderr 경고를 내고, attribute 로 선언되지 않은 관계는entity_audit가 literal suspect 로 보고한다. 남는 구멍은 attribute 선언은 있고 typed 는 아닌 관계 하나뿐이다.기존 KB 마이그레이션 (중요)
전각 값이 있는 KB 는
tools/check_conflicts.py가 exit 1 로 실패할 수 있다 — 경고가 아니라 게이트 실패다. 전각 값이("raw", 원문)키로 떨어져 단일값 관계에서 두 값이 되기 때문이다.조용한 쪽도 있다: 위 (2) 의 질의 미스는 엔진이 확인한 "없음" 과 구분되지 않는다.
둘 다 소스를 ASCII 로 고쳐 재수집하면 해소된다. 레포에 CHANGELOG 가 없어
docs/reference/typed-relations.md(+.en.md)에 적었다.구현 판단
re.ASCII플래그를 쓰지 않았다.\s까지 좁혀date(2020,<U+3000>1)이 조용히 깨진다(실측). 명시적[0-9]로만 좁혔고, 이 함정을 테스트로 고정했다._AMOUNT_RE의\D+단위 그룹은 유지.\D가Nd여집합이라 전각이 단위로 새지 않는다.tools/entity_audit.py의_LITERAL_RE는 일부러 좁히지 않았다 — 좁히면 전각 값이 audit 에서도 사라져 두 번째 표면화 경로가 닫힌다. 주석과 테스트로 이 발산을 고정했다.검증
pytest tests/unit— 706 passed / 13 skipped (baseline 655/13, 신규 51건)uvx ruff@0.15.17 check .— clean (CI 핀 버전)\d로 되돌려 전체 스위트 실행 → 9/9 전부 사살, 생존 0tests/*.sh하니스 40개 —upstream/main과 동일 (실패는 환경 기인 2건뿐:golden.shstep 2 = pyrewire,test_hwp_ingest.sh= 선행 결함)run_logic_checkrc=0,logic_report.txtgolden MATCH후속으로 분리한 것
tools/corroboration.py의 경쟁 버킷이 세 축 전부 원문 키 (별칭 정준화·typed 스칼라 폴딩 없음)