Skip to content

fix: 정책 질의가 고정한 인용 상수로 결과 행을 거른다 - #335

Merged
justinjoy merged 5 commits into
semantic-reasoning:mainfrom
SeoyunL:up-326
Jul 31, 2026
Merged

fix: 정책 질의가 고정한 인용 상수로 결과 행을 거른다#335
justinjoy merged 5 commits into
semantic-reasoning:mainfrom
SeoyunL:up-326

Conversation

@SeoyunL

@SeoyunL SeoyunL commented Jul 29, 2026

Copy link
Copy Markdown
Contributor

Closes #326.

무엇을 고쳤나

정책 술어 질의가 고정한 인용 상수로 결과 행을 거른다. tools/run_logic_check.pypolicy_result_linetools/ask_router.pyevaluate 양쪽.

이슈 본문보다 범위를 넓혔다

이슈는 첫 인자만 거르자고 제안했다. 구현하면서 두 가지가 드러나 모든 인자 위치로 확대했다.

첫째, 라우터에도 같은 결함이 있었다. needs_review("Carol", "low_conf")? 가 존재하지 않는 쌍에 1 row 를 반환한다 — 리포트만의 문제가 아니었다.

둘째, 첫 인자만 고치면 남은 오귀속이 탐지 불가능해진다. 수정 전에는 상수를 낀 모든 정책 질의가 똑같이 전체 extent 를 내서, 두 질의를 나란히 놓으면 필터가 죽었다는 걸 의심할 수 있었다. 첫 인자만 고치면 사용자는 "상수가 반영된다" 고 합리적으로 추론하고 둘째 줄을 검증된 답으로 읽는다:

- needs_review results: 0 rows                             ← 고쳐짐
- needs_review results: 3 rows; E=Alice; E=Carol; E=Dave   ← Alice·Carol·Dave 가 모두 stale 이라 주장

실제 stale 은 Carol 뿐이고, 사유 컬럼이 인용 상수라 bindings 에서 빠져 무엇이 날조인지 표시조차 없다. 부분 수정이 남은 버그의 유일한 탐지 신호를 없앤다.

함께 고친 것

arity 가드. 필터 확대가 직접 야기한 문제의 뒷정리다. 확대 전 pred("Alice", R, "zzz")? 는 1행이었지만, 가드 없이 확대하면 index 2 >= len(row)=20 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/unit691 passed / 13 skipped (baseline 655/13, 신규 36건)
  • uvx ruff@0.15.17 check . — clean (CI 핀 버전)
  • pre-fix: 신규 36건 중 33건이 upstream/main 에서 실패. 통과하는 3건은 "변수 전용 질의 출력을 바꾸지 않는다" 는 약속을 고정하는 무회귀 핀이다
  • 뮤테이션 5종 전부 사살 — 라우터 단독 변형 2종(짧은 행 가드 뒤집기, 라우터에만 NFC 폴딩)이 이전 라운드에서는 676건 전건을 생존했다. 0-arity 케이스를 파리티로 옮기고 NFD/NFC 케이스를 추가해 잡았다. 나머지 3종(리포트 args[0]-only 회귀, arity 가드 제거, 반향 무조건화)도 죽는다
  • pred(E, R)? 출력이 upstream/main바이트 동일 (4가지 shape 비교)
  • tests/*.sh 하니스 40개 — upstream/main 과 동일. 골든 무영향(골든 KB query.dl 에 정책 질의 0건)

후속으로 분리한 것

SeoyunL added 5 commits July 29, 2026 11:38
인용 상수가 표시에서만 빠지고 행을 거르지 않아, 리포트가 추론 행이 하나도 없는
엔티티에 다른 엔티티의 행을 그 엔티티 것으로 귀속시켰다. 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 justinjoy left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

결론: 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:37predicate(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:259for 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:156REASON_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_matchescommon.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 을, 표시 로직은 raw startswith('"') 를 쓴다. JSON 디코딩이 실패하는 인용 상수("C:\Users\x")에서 갈린다. 리포트 경로는 그 전에 아래 항목으로 죽어 도달 불가이고 main 도 동일하지만, 내부 불일치를 함정으로 남길 이유는 없다.
  • (별도 버그) /factlog check 가 JSON 무효 이스케이프에 트레이스백으로 죽는다. query.dlneeds_review("C:\Users\x", R)? 한 줄이면 main/head 양쪽에서 json.decoder.JSONDecodeError: Invalid \escape 미포착 크래시(common.py:1723run_logic_check.py:83).
  • common.py:1734-1736 원본 정정. _canonical_value docstring 의 "every query-match path routes value comparison through here" 가 거짓이라는 걸 이 PR 은 신규 docstring 두 곳에서 지적하지만 원본은 그대로다. common.py 만 읽는 다음 사람은 계속 오해한다.
  • 딕셔너리 접근 비대칭. run_logic_check.py:173inferred[predicate], ask_router.py:558inferred.get(predicate, set()). run_wirelogdefaultdict(set) 을 반환해(common.py:1677) 현재는 안전하다.
  • 테스트 docstring 과장. tests/unit/test_policy_query_filter.py:25-28 은 conftest 의 FACTLOG_ROOT 핀이 없으면 "파리티 테스트가 개발자의 실제 KB 를 읽는다" 고 하는데, conftest.py:23os.environ.setdefault 라 이미 export 된 값을 안 덮는다. 실제 KB 를 가리키고 돌려도 36건 전부 통과한다 — policy_predicates·run_wirelog 가 둘 다 monkeypatch 되어 의존성 자체가 없다.

머지한다.

@justinjoy
justinjoy merged commit bf586b3 into semantic-reasoning:main Jul 31, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

fix: policy_result_line 이 고정된 엔티티로 행을 거르지 않아 행이 없는 주체에 타 엔티티의 행이 귀속된다

2 participants