fix: 정책 질의가 고정한 인용 상수로 결과 행을 거른다 - #335
Conversation
인용 상수가 표시에서만 빠지고 행을 거르지 않아, 리포트가 추론 행이 하나도 없는
엔티티에 다른 엔티티의 행을 그 엔티티 것으로 귀속시켰다. logic_report.txt 는
SKILL.md 가 결론 전에 verbatim 으로 보이라고 규정한 산출물이므로, 지명된 주체에
대한 날조된 긍정이다.
필터를 첫 인자에만 넣지 않고 모든 위치에 넣는다. ask_router.evaluate 의 정책
분기도 args[0] 만 보고 있었으므로 같은 결함이 있었다 — pred(E, "stale")? 는 전체
extent 를, pred("Carol", "low_conf")? 는 Carol 의 행을 그의 것이 아닌 사유로
돌려줬다. 첫 인자만 고치면 첫 인자 질의가 정확해진 탓에 남은 오귀속이 검증된
답으로 읽힌다.
비교는 raw(arg_value)로 둔다. 두 경로를 바이트 단위로 동일하게 유지해 리포트와
ask 가 발산하지 않는 것이 이번 변경이 지키는 성질이다. 그 대가로 정책 경로는
common._canonical_value(#213 단일 chokepoint 주장)를 지나지 않는다 — NFD 저장 +
NFC 질의는 0 rows 로 나오며 검증된 부정으로 읽힐 수 있다. 양쪽 폴딩은 두 경로를
함께 고쳐야 하므로 범위 밖으로 둔다.
파리티 테스트는 동등성만이 아니라 절대 행 수를 단언한다. 동등성만 보면 두 경로가
나란히 틀려도 통과하는데, 이번 수정 전 비-첫-인자 위치가 정확히 그 상태였다.
업스트림 #326
query_args 는 읽지 못한 줄(끝에 ? 가 없는 등)에 [] 를 돌려준다. 인자가 없으면 고정된 상수도 없으므로 필터가 모든 행을 통과시켜, 같은 리포트의 Errors 가 "query must end with ?" 로 거절하고 있는 바로 그 줄에 술어 전체 extent 를 답으로 내놓았다. 오류와 날조된 답이 함께 나오는 것은 오류만 나오는 것보다 나쁘다. 이제 결과 줄을 내지 않고 Errors 가 말하게 둔다. 필터가 붙으면서 "Policy evaluation:" 의 extent 줄(전체 엔티티 기준 3 rows)과 질의 결과 줄(0 rows)이 한 화면에서 어긋나 보인다. 셋 중 질의 반향을 택했다. extent 줄은 tests/golden/logic_report.txt 가 고정하고 있고 섹션 머리말이 이미 "특정 질의의 답이 아니다"를 말하는 반면, 결과 줄에 질의를 밝히면 0 이 모순이 아니라 범위임이 드러나고 같은 술어에 대한 두 질의도 구분된다. 골든 KB 의 query.dl 에는 정책 질의가 없어 골든 산출물은 바뀌지 않는다. 업스트림 #326
반향이 모든 정책 결과 줄에 붙어 pred(E, R)? 의 출력 텍스트까지 바뀌었다. 이슈
수용 기준("변수 전용 질의의 기존 출력 불변")이 문자 그대로 깨진다. 앞선 커밋
본문의 "골든 산출물 불변" 은 사실이지만 "출력 불변" 은 거짓이었다 — 골든 KB 의
query.dl 에 정책 질의가 없어 골든만 우연히 무사했을 뿐이다.
반향의 목적은 extent 줄(전체 엔티티 3 rows)과 결과 줄(0 rows)의 모순처럼 보이는
어긋남을 범위로 읽히게 하는 것인데, 그 어긋남은 상수가 고정된 질의에서만
생긴다. 변수 전용 질의는 extent 를 그대로 보고하므로 extent 줄과 어긋날 수 없다.
따라서 인용 상수가 하나라도 있는 질의에만 반향을 붙인다.
행 집합과 바인딩 렌더는 두 shape 모두 변하지 않는다. 상수 고정 질의만 반향이
붙고, 변수 전용 질의는 upstream/main c6d359d 의 렌더와 바이트 단위로 같다 —
그 문자열을 테스트가 리터럴로 고정한다.
업스트림 #326
앞 커밋의 `if not args` 가드는 불완전했다. query_args 는 pred()? 에 빈 문자열
하나를 돌려주므로 가드를 통과해 전체 extent(3 rows)를 냈고, pred("Alice")? 와
pred(E, R, "zzz")? 는 arity 가 어긋난 채로 우연히 열이 맞는 상수만 걸러 그럴듯한
행 수를 냈다. 네 shape 모두 같은 리포트의 Errors 에 arity 오류가 찍히는 중이다.
"오류와 날조된 답이 함께 나오는 것이 오류만 나오는 것보다 나쁘다" 는 근거는
물음표 누락만이 아니라 arity 오류에도 그대로 적용된다.
가드를 validate_query(그리고 classify_query)와 같은 기준인 len(args) != 2 로
맞춘다. 리포트는 결과 줄을 내지 않고, 라우터는 count 분기(#257)와 같은 방식으로
NotImplementedError 를 던진다(cmd_evaluate 가 error JSON 으로 바꾼다).
이로써 malformed 질의에서 두 경로의 발산도 사라진다. 이전에는
pred("Bob", R)(물음표 없음)에 대해 라우터가 count=3, 리포트가 무응답이었다.
cmd_evaluate 는 classify 없이 evaluate 를 직접 부르므로 라우터 쪽 가드가 필요하다
— classify_query 가 BAD_ARITY 로 거르는 render 경로는 애초에 여기 닿지 않는다.
초과 arity 질의가 양쪽 "0 rows"(검증된 부정처럼 읽힘)로 렌더되던 것도 함께
해소된다.
evaluate 의 docstring 이 정책 분기를 "quoted entity argument 로 선택적 필터" 라고
설명하던 stale 한 서술도 실제 동작(모든 인자 위치)으로 고친다.
업스트림 #326
policy_row_matches 두 사본의 docstring 은 "어긋나면 파리티 테스트가 실패한다" 고 주장했지만 사실이 아니었다. 라우터만 변형한 뮤턴트 두 종이 생존한다 — (a) 짧은 행 가드를 뒤집기, (b) 라우터에만 NFC 폴딩 추가. 파리티 케이스가 전부 2컬럼 ASCII 행이라 어느 쪽도 결과가 달라지지 않기 때문이다. 복제를 정당화하는 근거가 근거로 작동하지 않았다. 0-arity 행 케이스를 리포트 단독 단언에서 파리티로 옮겨 라우터도 함께 단언하고, NFD 저장 + NFC 질의를 "양쪽 0 rows" 로 고정한다. 후자는 현재 동작을 고정하는 것이지 정책 변경이 아니다 — 금지하는 것은 한쪽 경로에서만 폴딩을 고치는 일이다. 두 케이스 모두 비-vacuous 하도록, 같은 extent 를 다른 질의로 짚어 행이 실제로 도달 가능함을 함께 단언한다. 실측: 두 뮤턴트 모두 이제 죽는다. (a) test_zero_arity_row_is_dropped_by_both_paths 실패, (b) test_nfd_stored_entity_does_not_meet_an_nfc_query_on_either_path 실패. docstring 에도 어느 케이스가 이 부하를 지는지와 삭제 금지를 적었다. 업스트림 #326
justinjoy
left a comment
There was a problem hiding this comment.
결론: MERGE
본문의 검증 주장을 전부 독립 실행으로 재현했고 8건 모두 정확했다. 회귀는 찾지 못했다.
| 주장 | 결과 |
|---|---|
| pytest 691 passed / 13 skipped (합 704) | 확인 — 이 환경은 704 passed, 0 skipped(13건은 환경 게이트) |
| pre-fix 36건 중 33 실패 / 3 통과 | 정확히 일치 — c6d359d 워크트리에 신규 테스트 2파일을 복사해 33 failed, 3 passed |
| 뮤테이션 5종 전부 사살 | 확인, 추가로 8종을 더 만들어 13/13 사살 |
pred(E,R)? 바이트 동일 (4 shape) |
확인, 실제로는 7 shape 무변화 — (E,R), (X,Y), (_,R), (E,_), ( E , R ), (E,42), (E,stale) |
| 골든 무영향 | 확인 — 골든/샘플 query.dl 에 정책 질의 0건, golden.sh 5 passed |
| tests/*.sh 40개 동일 | 확인 — 변경 파일은 4개뿐, 셸 스위트 40개 전부 GREEN |
두 policy_row_matches 동일 |
확인 — AST 정규화 비교로 본문·시그니처·반환타입 완전 일치 |
라우터 NotImplementedError 가 우아함 |
확인 — 트레이스백 0건 |
파리티 테스트가 실제로 드리프트를 잡는다
한쪽만 바꾸는 변형 7종을 포함해 13종을 적용했고 전부 죽는다:
M1 router-only 짧은행 가드 뒤집기 KILLED ← test_zero_arity_row_is_dropped_by_both_paths (정확히 1건)
M2 router-only NFC 폴딩 KILLED ← test_nfd_stored_entity_does_not_meet_an_nfc_query_on_either_path (정확히 1건)
M3 리포트 args[0]-only 회귀 KILLED (6건)
M4 arity 가드 제거 (양쪽) KILLED (15건)
M5 반향 무조건화 KILLED (1건)
X1 report-only 짧은행 가드 KILLED X5 arity 가드 제거: ROUTER만 KILLED
X2 report-only NFC 폴딩 KILLED X6 arity !=2 → <2 (양쪽) KILLED
X3 router-only args[0]-only KILLED X7 반향 완전 제거 KILLED
X4 arity 가드 제거: REPORT만 KILLED X8 라우터 필터 통째 제거 KILLED
policy_row_matches docstring 의 "Two of its cases carry that load — the 0-arity row and the NFD ... do not delete those two" 는 실측으로 정확하다. M1 은 0-arity 테스트가, M2 는 NFD 테스트가 각각 단독으로 사살한다.
범위 확대 판단이 옳다
이슈의 "첫 인자만" 을 거부한 것이 맞다. skills/factlog/references/text-to-datalog.md:37 이 predicate(X, reason)? 템플릿을 가르치므로 위치 1 의 인용 상수는 이 저장소가 스스로 생성을 유도하는 형태다. 첫 인자만 고쳤다면 가이드가 권장하는 바로 그 모양이 깨진 채로 남고, 본문 지적대로 남은 버그의 유일한 탐지 신호까지 사라진다.
실제 KB 재현이 이 PR 의 가치를 가장 잘 보여준다:
main (c6d359d):
- needs_review results: 3 rows; R=uses_fastapi; R=uses_fastapi; R=uses_fastapi ← needs_review("Acme", R)?
- needs_review results: 3 rows; R=uses_fastapi; R=uses_fastapi; R=uses_fastapi ← needs_review("Nonexistent Corp", R)?
PR head:
- needs_review results (query: needs_review("Acme", R)?): 1 rows; R=uses_fastapi
- needs_review results (query: needs_review("Nonexistent Corp", R)?): 0 rows
main 은 KB 에 존재하지도 않는 엔티티에 대해 "3 rows" 양성을 날조한다 — SKILL.md:28 이 결론 전에 verbatim 제시하라고 지정한 산출물에서. 출력이 바뀌는 breaking 이지만 옳은 종류의 breaking 이다. 변수 전용 질의는 바이트 동일이 핀으로 고정돼 있고, 하류에서 이 카운트를 소비하는 곳도 없다.
arity 가드는 기능을 없애지 않는다
len(args) != 2 는 이미 저장소 전체의 하드 계약이다. generate_logic_policy.py:202 가 .decl {pred}(entity: symbol, reason: symbol) 만 생성하고, run_logic_check.py:259 의 for target, reason in sorted(...) 가 다른 arity 에서 이미 ValueError 로 죽는다. tests/unit/test_typed_projection.py:165-168 도 "an arity-1 head crashes run_logic_check.py's 2-tuple unpack" 라고 적고 있다. arity-1 정책 술어로 실제 KB 를 만들어 확인한 결과 main/head 양쪽에서 /factlog check 자체가 크래시한다. 즉 이 가드는 기능 상실이 아니라 일관성 확보다.
라우터의 거부도 크래시가 아니다:
evaluate 'needs_review("Acme")?' → {"error": "policy query must have entity and reason arguments"} rc=2
render 'needs_review("Acme")?' → {"route": "wiki", ...} rc=0
validate 'needs_review("Acme")?' → {"ok": false, "code": "bad_arity", ...} rc=0
반향과 필터링
(query: ...) 는 소비자를 깨지 않는다. SKILL.md:28,582,711 은 리포트를 verbatim 출력하라고만 하고 파싱하지 않는다. hooks/gate_check.sh 는 존재·mtime 만 본다. 정규식 소비자는 tests/smoke.sh:183(^- relation results:)과 tests/test_count_check.sh:63(count results:) 둘뿐이고 정책 술어가 아니다. tests/golden/logic_report.txt 에 정책 결과 줄이 없으므로 골든 재생성도 불필요하다.
필터링 자체도 29개 shape 차등 프로브(main vs head)에서 report/router 파리티 29/29 완전 일치, MISMATCH 0건이다. 콤마("Paris, France"), 이스케이프 따옴표("He said \"hi\""), 괄호("f(x)"), 빈 문자열, 숫자 문자열("42"), 대소문자, 앞뒤 공백 전부 정상이다 — common._query_args(common.py:1692)가 string-aware 파서라 그렇다.
raw 비교 유지와 #334
수용 가능하다. 위치 1(reason 축)에 검증자 backstop 이 없는 것은 맞지만, generate_logic_policy.py:156 의 REASON_RE = [a-z0-9_]+ 가 생성되는 모든 reason 을 ASCII 로 묶으므로 NFC/NFD 가 바이트 동일하다. 잔여 노출은 손으로 쓴 logic-policy.extra.dl 에 비-ASCII reason 리터럴을 넣은 경우뿐이다. 위치 0 은 run_logic_check.py:83-84 의 non-engine entity 경고와 classify_query 의 wiki 라우팅이 받쳐준다.
#334 와의 상호작용은 없다. 파일 겹침 0이고, NFC 폴딩은 #334 가 도입하는 게 아니라 common.py:1742(#213)로 이미 main 에 있다. 실제 불일치는 선행 문제이며 #213 소관이다.
후속으로 올려주면 좋을 것 (전부 비차단)
policy_row_matches를common.py로 승격. 중복 사유("hoisting it there is a wider change than this fix needs")는 검증을 못 견딘다 —common.py:1767-1782가 "Public query-parsing API ... ask_router and run_logic_check both depend on them" 이라고 집을 이미 명시하고 있고, 이 함수가 쓰는query_args/arg_value/is_quoted_string이 전부 거기서 export 되며 두 모듈이 이미 import 중이라 순환도 없다. 6줄 함수를 안 옮기려고 32줄 docstring 을 두 벌 실었다. 파리티 테스트가 실측으로 유효하므로 막지는 않지만, 지금 구조는 두 복제본이 서로를 "raw 를 유지하는 이유" 로 인용하는 고정점을 만들어 #213 후속 수정을 더 어렵게 만든다(두 파일을 lockstep 으로 고치거나, 약화하지 말라고 적힌 파리티 테스트를 깨야 한다). 명시적 이슈로 올려두는 편이 낫다.validate_query에_is_valid_arg검사 추가. 본문이 후속으로 분리한 항목인데, 실제 증상을 확인했다:needs_review(alice, R)?와needs_review(, R)?가errors: 0, warnings: 0으로 전체 extent 를 답하며alice=Alice같은 무의미한 바인딩을 찍는다.classify_query(common.py:1972)는 거부한다. main 과 동일한 선행 결함이고 라벨이 눈에 띄게 이상해 오독 가능성은 낮지만, 이 PR 이 arity 가드는 두 검증기 간에 정렬하면서 인자-유효성은 정렬하지 않아 #326 결함군의 잔여분이 남았다.run_logic_check.py:178의 인용 판정 통일. 같은 함수 안에서 필터·반향은is_quoted_string을, 표시 로직은 rawstartswith('"')를 쓴다. JSON 디코딩이 실패하는 인용 상수("C:\Users\x")에서 갈린다. 리포트 경로는 그 전에 아래 항목으로 죽어 도달 불가이고 main 도 동일하지만, 내부 불일치를 함정으로 남길 이유는 없다.- (별도 버그)
/factlog check가 JSON 무효 이스케이프에 트레이스백으로 죽는다.query.dl에needs_review("C:\Users\x", R)?한 줄이면 main/head 양쪽에서json.decoder.JSONDecodeError: Invalid \escape미포착 크래시(common.py:1723←run_logic_check.py:83). common.py:1734-1736원본 정정._canonical_valuedocstring 의 "every query-match path routes value comparison through here" 가 거짓이라는 걸 이 PR 은 신규 docstring 두 곳에서 지적하지만 원본은 그대로다.common.py만 읽는 다음 사람은 계속 오해한다.- 딕셔너리 접근 비대칭.
run_logic_check.py:173은inferred[predicate],ask_router.py:558은inferred.get(predicate, set()).run_wirelog가defaultdict(set)을 반환해(common.py:1677) 현재는 안전하다. - 테스트 docstring 과장.
tests/unit/test_policy_query_filter.py:25-28은 conftest 의FACTLOG_ROOT핀이 없으면 "파리티 테스트가 개발자의 실제 KB 를 읽는다" 고 하는데,conftest.py:23이os.environ.setdefault라 이미 export 된 값을 안 덮는다. 실제 KB 를 가리키고 돌려도 36건 전부 통과한다 —policy_predicates·run_wirelog가 둘 다 monkeypatch 되어 의존성 자체가 없다.
머지한다.
Closes #326.
무엇을 고쳤나
정책 술어 질의가 고정한 인용 상수로 결과 행을 거른다.
tools/run_logic_check.py의policy_result_line과tools/ask_router.py의evaluate양쪽.이슈 본문보다 범위를 넓혔다
이슈는 첫 인자만 거르자고 제안했다. 구현하면서 두 가지가 드러나 모든 인자 위치로 확대했다.
첫째, 라우터에도 같은 결함이 있었다.
needs_review("Carol", "low_conf")?가 존재하지 않는 쌍에 1 row 를 반환한다 — 리포트만의 문제가 아니었다.둘째, 첫 인자만 고치면 남은 오귀속이 탐지 불가능해진다. 수정 전에는 상수를 낀 모든 정책 질의가 똑같이 전체 extent 를 내서, 두 질의를 나란히 놓으면 필터가 죽었다는 걸 의심할 수 있었다. 첫 인자만 고치면 사용자는 "상수가 반영된다" 고 합리적으로 추론하고 둘째 줄을 검증된 답으로 읽는다:
실제 stale 은 Carol 뿐이고, 사유 컬럼이 인용 상수라 bindings 에서 빠져 무엇이 날조인지 표시조차 없다. 부분 수정이 남은 버그의 유일한 탐지 신호를 없앤다.
함께 고친 것
arity 가드. 필터 확대가 직접 야기한 문제의 뒷정리다. 확대 전
pred("Alice", R, "zzz")?는 1행이었지만, 가드 없이 확대하면index 2 >= len(row)=2로 0 rows — 이번 변경이 새로 만들어내는 날조된 verified-negative다. 리포트는 결과 줄을 내지 않고, 라우터는count분기(#257)와 같은 방식으로NotImplementedError를 던진다. 기준을validate_query·classify_query와 동일한len(args) != 2로 맞췄다.질의 반향.
Policy evaluation:의 extent 줄(3 rows)과 질의 결과 줄(0 rows)이 한 화면에서 모순처럼 읽힌다. 인용 상수가 고정된 질의에만(query: ...)를 붙인다 — 변수 전용 질의는upstream/main과 바이트 동일을 유지한다(테스트가 리터럴로 고정).남는 것
raw 비교를 유지했다. NFD 로 저장된 주체를 NFC 로 질의하면 이제 양쪽 다
0 rows다 — 검증된 부정으로 오독될 수 있다. 리포트만 폴딩하면 라우터와 파리티가 깨지므로 두 경로를 함께 고쳐야 하고, 그건factlog/common.py:1727의 "every query-match path routes value comparison through here" 주장이 이미 거짓인 것과 같은 뿌리다(upstream/main 의 라우터도 raw 비교였다). 현재 동작을 테스트로 고정하고 두 docstring 에 기록했다 — 그 테스트가 금지하는 것은 한쪽 경로만 고치는 것이다.policy_row_matches가 두 파일에 복제돼 있다. 자연스러운 자리는common.py인데 이번 diff 를tools/2파일 + 테스트로 유지하는 편이 리뷰 비용이 낮다고 판단했다. 드리프트는 파리티 테스트가 잡는다 — 아래 뮤테이션 참조.검증
pytest tests/unit— 691 passed / 13 skipped (baseline 655/13, 신규 36건)uvx ruff@0.15.17 check .— clean (CI 핀 버전)upstream/main에서 실패. 통과하는 3건은 "변수 전용 질의 출력을 바꾸지 않는다" 는 약속을 고정하는 무회귀 핀이다args[0]-only 회귀, arity 가드 제거, 반향 무조건화)도 죽는다pred(E, R)?출력이upstream/main과 바이트 동일 (4가지 shape 비교)tests/*.sh하니스 40개 —upstream/main과 동일. 골든 무영향(골든 KBquery.dl에 정책 질의 0건)후속으로 분리한 것
policy_row_matches를common.py로 승격run_logic_check.validate_query를common.classify_query의 인자 검사와 정렬 —needs_review(alice, R)?가 오류 없이 무의미한 바인딩을 찍는다(선행 결함,upstream/main과 바이트 동일). fix: count 질의에 인자 형태 가드가 없어 게이트가 거부하는 질의를 리포트가 검증된 수치로 답한다 #328 과 같은 뿌리다common.py:1727의 chokepoint 주장 정정 + 정책 경로 NFC 폴딩 (bug(ingest): 확장자 다른 동명 원본이 같은 변환본 어간으로 충돌 → report.pptx 유실·provenance 오염 #213 후속)