From c90be16c29371319e028e0d850a35e72fc3f3cd5 Mon Sep 17 00:00:00 2001 From: Seoyun Lee Date: Wed, 29 Jul 2026 11:36:13 +0900 Subject: [PATCH 1/5] =?UTF-8?q?fix:=20=EC=A0=95=EC=B1=85=20=EC=A7=88?= =?UTF-8?q?=EC=9D=98=EC=9D=98=20=EC=9D=B8=EC=9A=A9=20=EC=83=81=EC=88=98?= =?UTF-8?q?=EB=A5=BC=20=EB=AA=A8=EB=93=A0=20=EC=9D=B8=EC=9E=90=20=EC=9C=84?= =?UTF-8?q?=EC=B9=98=EC=97=90=EC=84=9C=20=ED=95=84=ED=84=B0=EB=A1=9C=20?= =?UTF-8?q?=EC=A0=81=EC=9A=A9=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 인용 상수가 표시에서만 빠지고 행을 거르지 않아, 리포트가 추론 행이 하나도 없는 엔티티에 다른 엔티티의 행을 그 엔티티 것으로 귀속시켰다. 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 --- tests/unit/test_policy_query_filter.py | 135 +++++++++++++++++++++++++ tools/ask_router.py | 47 ++++++++- tools/run_logic_check.py | 41 +++++++- 3 files changed, 217 insertions(+), 6 deletions(-) create mode 100644 tests/unit/test_policy_query_filter.py diff --git a/tests/unit/test_policy_query_filter.py b/tests/unit/test_policy_query_filter.py new file mode 100644 index 00000000..f0918abf --- /dev/null +++ b/tests/unit/test_policy_query_filter.py @@ -0,0 +1,135 @@ +# SPDX-License-Identifier: Apache-2.0 +"""Regression tests: a policy query's quoted constants must FILTER rows (#326). + +``policy_result_line`` printed the whole extent of the policy predicate no +matter which entity the query pinned, so ``needs_review("Bob", R)?`` reported +three rows for a Bob the engine inferred NOTHING about — other subjects' reasons +rendered as Bob's. The report is the artifact SKILL.md tells the reader to show +verbatim before stating a conclusion, so a fabricated positive there is not a +cosmetic defect. + +The filter must apply at EVERY argument position, not just the first: fixing +only ``args[0]`` would leave ``pred(E, "stale")?`` mis-attributing rows while +making the first-argument form accurate — which removes the only signal a reader +had that the second line is untrustworthy. + +``ask_router.evaluate`` (the ``ask`` path) had the same defect at positions +other than the first. The parity class below pins the two paths together. + +FACTLOG_ROOT is bound to a throwaway dir by ``tests/unit/conftest.py`` BEFORE +any tool module is imported, which matters here: ``ask_router`` re-exports +``FACTLOG_ROOT`` at import time from argv/env, so without that pin the parity +test would read the developer's real knowledge base. +""" +from __future__ import annotations + +import re + +import pytest + +import ask_router +import run_logic_check as rlc + +PREDICATE = "needs_review" +INFERRED = { + PREDICATE: { + ("Alice", "low_conf"), + ("Carol", "stale"), + ("Dave", "no_source"), + } +} + + +def _row_count(report_line: str) -> int: + """Extract N from ' results ...: N rows; ...'.""" + match = re.search(r"(\d+) rows", report_line) + assert match, f"unparseable result line: {report_line!r}" + return int(match.group(1)) + + +def _report_rows(draft: str, inferred=None) -> int: + return _row_count(rlc.policy_result_line(PREDICATE, draft, inferred or INFERRED)) + + +def _router_rows(monkeypatch, draft: str, inferred=None) -> int: + monkeypatch.setattr(ask_router, "policy_predicates", lambda program: {PREDICATE}) + monkeypatch.setattr(ask_router, "run_wirelog", lambda: inferred or INFERRED) + return ask_router.evaluate(draft, [])["count"] + + +class TestPolicyResultLineFiltersFixedEntity: + def test_named_entity_reports_only_its_own_rows(self): + line = rlc.policy_result_line(PREDICATE, f'{PREDICATE}("Alice", R)?', INFERRED) + assert _row_count(line) == 1 + assert "low_conf" in line + assert "stale" not in line and "no_source" not in line + + def test_entity_with_no_inferred_rows_reports_zero(self): + line = rlc.policy_result_line(PREDICATE, f'{PREDICATE}("Bob", R)?', INFERRED) + assert _row_count(line) == 0 + assert "low_conf" not in line and "stale" not in line and "no_source" not in line + + def test_second_argument_constant_also_filters(self): + # The defect is not first-argument-specific: a reason-pinned query must + # not report the other reasons' rows either. + line = rlc.policy_result_line(PREDICATE, f'{PREDICATE}(E, "stale")?', INFERRED) + assert _row_count(line) == 1 + assert "Carol" in line + assert "Alice" not in line and "Dave" not in line + + def test_second_argument_constant_with_no_match_reports_zero(self): + line = rlc.policy_result_line(PREDICATE, f'{PREDICATE}(E, "missing_reason")?', INFERRED) + assert _row_count(line) == 0 + + def test_both_arguments_constant_matching_row(self): + line = rlc.policy_result_line(PREDICATE, f'{PREDICATE}("Carol", "stale")?', INFERRED) + assert _row_count(line) == 1 + + def test_both_arguments_constant_crossed_pair_reports_zero(self): + # Carol and low_conf each exist, but not together — a per-column filter + # that ORed the positions would wrongly report a row here. + line = rlc.policy_result_line(PREDICATE, f'{PREDICATE}("Carol", "low_conf")?', INFERRED) + assert _row_count(line) == 0 + + def test_all_variable_query_still_reports_full_extent(self): + line = rlc.policy_result_line(PREDICATE, f"{PREDICATE}(E, R)?", INFERRED) + assert _row_count(line) == 3 + assert "E=Alice, R=low_conf" in line + + def test_empty_row_dropped_when_a_constant_is_pinned(self): + # A 0-arity row cannot satisfy a pinned constant, so it is filtered out; + # an all-variable query keeps reporting it, as before. + inferred = {PREDICATE: {(), ("Alice", "low_conf")}} + assert _report_rows(f'{PREDICATE}("Alice", R)?', inferred) == 1 + assert _report_rows(f"{PREDICATE}(E, R)?", inferred) == 2 + + +class TestReportRouterParity: + """The report and the ``ask`` router must answer a policy query identically. + + Parity is NOT correctness: two paths can agree and both be wrong, which is + exactly the state before this fix at non-first argument positions. So every + case asserts the ABSOLUTE row count as well as the agreement, and the router + side calls ``ask_router.evaluate`` directly rather than re-implementing the + filter in the test (a re-implementation would prove nothing about either + production path). + """ + + @pytest.mark.parametrize( + ("draft", "expected"), + [ + (f'{PREDICATE}("Alice", R)?', 1), + (f'{PREDICATE}("Bob", R)?', 0), + (f'{PREDICATE}(E, "stale")?', 1), + (f'{PREDICATE}(E, "missing_reason")?', 0), + (f'{PREDICATE}("Carol", "stale")?', 1), + (f'{PREDICATE}("Carol", "low_conf")?', 0), + (f"{PREDICATE}(E, R)?", 3), + ], + ) + def test_report_and_router_agree_on_the_verified_row_count(self, monkeypatch, draft, expected): + report_rows = _report_rows(draft) + router_rows = _router_rows(monkeypatch, draft) + assert report_rows == expected + assert router_rows == expected + assert report_rows == router_rows diff --git a/tools/ask_router.py b/tools/ask_router.py index 062f81d8..a6c6f0a9 100644 --- a/tools/ask_router.py +++ b/tools/ask_router.py @@ -420,6 +420,43 @@ def _reachable_pairs(facts: list[dict[str, str]]) -> set[tuple[str, str]]: return pairs +def policy_row_matches(args: list[str], row: tuple[str, ...] | list[str]) -> bool: + """True when *row* satisfies every quoted constant *args* pins, by position. + + A quoted constant is a FILTER, at whatever position it appears. This branch + used to test args[0] only, so `pred(E, "stale")?` returned the whole extent + and `pred("Carol", "low_conf")?` returned Carol's row under a reason that is + not hers — a fabricated positive for the exact pair the user asked about. + + A row shorter than the pinned position cannot satisfy the constant, so the + 0-arity row an engine may emit is dropped from a constant-pinned query (an + all-variable query still returns it). + + Comparison is RAW (`arg_value` only), deliberately not `canonical_value` + which the relation branch uses: it mirrors run_logic_check's report path + exactly so `ask` and the report cannot diverge, which is the property + tests/unit/test_policy_query_filter.py pins. This means the policy path does + NOT go through the "#213 single query-value comparison chokepoint" + (common.py `_canonical_value`), contrary to what that docstring claims for + every query-match path. Measured consequence: an NFD-stored entity queried + with an NFC-typed constant now yields 0 rows, which reads as a verified + negative. Folding both sides belongs in one place for BOTH paths and is out + of scope here; see #213. + + The matching rule is kept identical to run_logic_check's `policy_row_matches` + (same body, module-specific docstring). The natural home is common.py + alongside the other query-parsing helpers, but hoisting it there is a wider + change than this fix needs; the report/router parity test fails if the two + copies ever drift. + """ + for index, arg in enumerate(args): + if not is_quoted_string(arg): + continue + if index >= len(row) or arg_value(arg) != row[index]: + return False + return True + + def evaluate(draft: str, facts: list[dict[str, str]]) -> dict[str, object]: """Evaluate a validated engine query: relation, path, or a policy predicate. @@ -500,11 +537,11 @@ def evaluate(draft: str, facts: list[dict[str, str]]) -> dict[str, object]: inferred = run_wirelog() except Exception as exc: # noqa: BLE001 — engine/loader raise non-FactlogError too return {"rows": [], "count": 0, "policy_unevaluable": str(exc)} - rows = [] - for row in sorted(inferred.get(predicate, set())): - if args and is_quoted_string(args[0]) and (not row or arg_value(args[0]) != row[0]): - continue - rows.append(list(row)) + rows = [ + list(row) + for row in sorted(inferred.get(predicate, set())) + if policy_row_matches(args, row) + ] return {"rows": rows, "count": len(rows)} raise NotImplementedError(f"engine evaluation of predicate '{predicate}' is not supported") diff --git a/tools/run_logic_check.py b/tools/run_logic_check.py index 2a180db4..034959c3 100644 --- a/tools/run_logic_check.py +++ b/tools/run_logic_check.py @@ -20,6 +20,7 @@ LOGIC_POLICY_DL, run_wirelog, arg_value, + is_quoted_string, query_args, quoted_constants, ) @@ -93,9 +94,47 @@ def validate_query(line: str, entities: set[str], policy_query_predicates: set[s return errors, warnings +def policy_row_matches(args: list[str], row: tuple[str, ...] | list[str]) -> bool: + """True when *row* satisfies every quoted constant *args* pins, by position. + + A quoted constant is a FILTER, at whatever position it appears — not merely a + binding the display omits. Filtering only args[0] would still let + ``pred(E, "stale")?`` report the other reasons' rows, and would do so while + the first-argument form answers correctly, which is worse than filtering + nothing: the reader loses the one signal that the second line is untrustworthy. + + A row shorter than the pinned position cannot satisfy the constant, so the + 0-arity row an engine may emit is dropped from a constant-pinned query (it + still shows up for an all-variable query, as before). + + Comparison is RAW (``arg_value`` only), deliberately not + ``common.canonical_value``: it mirrors ask_router's policy branch exactly so + the report and ``ask`` cannot diverge, which is the property + tests/unit/test_policy_query_filter.py pins. This means the policy path does + NOT go through the "#213 single query-value comparison chokepoint" + (common.py `_canonical_value`), contrary to what that docstring claims for + every query-match path. Measured consequence: an NFD-stored entity queried + with an NFC-typed constant now yields `0 rows`, which reads as a verified + negative. Folding both sides belongs in one place for BOTH paths and is out + of scope here; see #213. + + The matching rule is kept identical to ask_router's `policy_row_matches` + (same body, module-specific docstring). The natural home is common.py + alongside the other query-parsing helpers, but hoisting it there is a wider + change than this fix needs; the report/router parity test fails if the two + copies ever drift. + """ + for index, arg in enumerate(args): + if not is_quoted_string(arg): + continue + if index >= len(row) or arg_value(arg) != row[index]: + return False + return True + + def policy_result_line(predicate: str, line: str, inferred: dict[str, set[tuple[str, ...]]]) -> str: - rows = sorted(inferred[predicate]) args = query_args(line) + rows = [row for row in sorted(inferred[predicate]) if policy_row_matches(args, row)] values: list[str] = [] for row in rows: bindings = [] From 64adeecce074bddcc45dc69fbce9a8aa7c3909c4 Mon Sep 17 00:00:00 2001 From: Seoyun Lee Date: Wed, 29 Jul 2026 11:37:13 +0900 Subject: [PATCH 2/5] =?UTF-8?q?fix:=20=ED=8C=8C=EC=8B=B1=20=EB=B6=88?= =?UTF-8?q?=EA=B0=80=ED=95=9C=20=EC=A0=95=EC=B1=85=20=EC=A7=88=EC=9D=98?= =?UTF-8?q?=EC=97=90=20=EA=B2=B0=EA=B3=BC=20=EC=A4=84=EC=9D=84=20=EB=82=B4?= =?UTF-8?q?=EC=A7=80=20=EC=95=8A=EA=B3=A0,=20=EA=B2=B0=EA=B3=BC=20?= =?UTF-8?q?=EC=A4=84=EC=9D=B4=20=EC=A7=88=EC=9D=98=EB=A5=BC=20=EB=B0=9D?= =?UTF-8?q?=ED=9E=8C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../unit/test_policy_result_line_rendering.py | 70 +++++++++++++++++++ tools/run_logic_check.py | 27 ++++++- 2 files changed, 94 insertions(+), 3 deletions(-) create mode 100644 tests/unit/test_policy_result_line_rendering.py diff --git a/tests/unit/test_policy_result_line_rendering.py b/tests/unit/test_policy_result_line_rendering.py new file mode 100644 index 00000000..3b4c4460 --- /dev/null +++ b/tests/unit/test_policy_result_line_rendering.py @@ -0,0 +1,70 @@ +# SPDX-License-Identifier: Apache-2.0 +"""Regression tests: what a policy query result line may claim (#326). + +Two rendering defects that survive the quoted-constant filter: + +1. An UNPARSEABLE query (no trailing '?', say) makes ``query_args`` return []. + With no args, no constant is pinned, so the filter passes every row and the + line answers with the predicate's whole extent — for a query the report is + simultaneously rejecting in its own Errors section ('query must end with ?'). + An error plus a fabricated full-extent answer is worse than the error alone. + +2. The filtered result line and the "Policy evaluation:" extent line sit a few + lines apart in the same report and now legitimately disagree (3 rows there, + 0 rows here). Echoing the query is what makes that pair readable as scope + rather than contradiction; the extent line is deliberately left alone (it is + pinned by tests/golden/logic_report.txt). +""" +from __future__ import annotations + +import run_logic_check as rlc + +PREDICATE = "needs_review" +INFERRED = { + PREDICATE: { + ("Alice", "low_conf"), + ("Carol", "stale"), + ("Dave", "no_source"), + } +} + + +class TestUnparseableQueryEmitsNoResultLine: + def _evaluate(self, monkeypatch, line): + monkeypatch.setattr(rlc, "query_lines", lambda: [line]) + return rlc.evaluate_queries([], INFERRED, {PREDICATE}) + + def test_missing_question_mark_produces_no_result(self, monkeypatch): + assert self._evaluate(monkeypatch, f'{PREDICATE}("Bob", R)') == [] + + def test_bare_predicate_produces_no_result(self, monkeypatch): + assert self._evaluate(monkeypatch, PREDICATE) == [] + + def test_unparseable_line_returns_none_not_a_full_extent_string(self, monkeypatch): + assert rlc.policy_result_line(PREDICATE, f'{PREDICATE}("Bob", R)', INFERRED) is None + + def test_wellformed_query_still_produces_a_result(self, monkeypatch): + results = self._evaluate(monkeypatch, f'{PREDICATE}("Alice", R)?') + assert len(results) == 1 + assert "1 rows" in results[0] + + +class TestResultLineNamesTheQueryItAnswers: + def test_result_line_echoes_the_query(self): + draft = f'{PREDICATE}("Bob", R)?' + line = rlc.policy_result_line(PREDICATE, draft, INFERRED) + # Without the echo, '0 rows' here reads as a contradiction of the + # 'needs_review: 3 rows' extent line printed just above it. + assert draft in line + assert "0 rows" in line + + def test_echo_distinguishes_two_queries_on_the_same_predicate(self, monkeypatch): + monkeypatch.setattr( + rlc, + "query_lines", + lambda: [f'{PREDICATE}("Alice", R)?', f'{PREDICATE}("Bob", R)?'], + ) + first, second = rlc.evaluate_queries([], INFERRED, {PREDICATE}) + assert "Alice" in first and "1 rows" in first + assert "Bob" in second and "0 rows" in second + assert first != second diff --git a/tools/run_logic_check.py b/tools/run_logic_check.py index 034959c3..ab3f4b0b 100644 --- a/tools/run_logic_check.py +++ b/tools/run_logic_check.py @@ -132,8 +132,27 @@ def policy_row_matches(args: list[str], row: tuple[str, ...] | list[str]) -> boo return True -def policy_result_line(predicate: str, line: str, inferred: dict[str, set[tuple[str, ...]]]) -> str: +def policy_result_line(predicate: str, line: str, inferred: dict[str, set[tuple[str, ...]]]) -> str | None: + """Render one policy query's result, or None when the query is unparseable. + + `query_args` returns [] for a line it cannot read (a missing trailing '?', + say). "No args" is not "no constants to honour" — it means the query was + never understood, so answering it with the predicate's whole extent invents + an answer for a line `validate_query` is reporting as an error in the same + report. Emitting nothing leaves the Errors section to speak. + + The query is echoed because this line and the "Policy evaluation:" extent + line (": N rows", the count over ALL entities) sit a few lines apart + and now legitimately disagree — 3 rows there, 0 rows here. Naming the query + that produced the 0 is what makes the pair readable as scope rather than + contradiction, and it also tells two queries on the same predicate apart. + The extent line itself is left untouched: it is pinned by + tests/golden/logic_report.txt, and its section header already says it is the + policy evaluation rather than the answer to any one query. + """ args = query_args(line) + if not args: + return None rows = [row for row in sorted(inferred[predicate]) if policy_row_matches(args, row)] values: list[str] = [] for row in rows: @@ -143,7 +162,7 @@ def policy_result_line(predicate: str, line: str, inferred: dict[str, set[tuple[ bindings.append(f"{arg}={value}") values.append(", ".join(bindings) if bindings else ", ".join(row)) suffix = "; " + "; ".join(values) if values else "" - return f"{predicate} results: {len(rows)} rows{suffix}" + return f"{predicate} results (query: {line}): {len(rows)} rows{suffix}" def evaluate_queries(facts: list[dict[str, str]], inferred: dict[str, set[tuple[str, ...]]], policy_query_predicates: set[str]) -> list[str]: @@ -151,7 +170,9 @@ def evaluate_queries(facts: list[dict[str, str]], inferred: dict[str, set[tuple[ for line in query_lines(): predicate = line.split("(", 1)[0] if predicate in policy_query_predicates: - results.append(policy_result_line(predicate, line, inferred)) + result_line = policy_result_line(predicate, line, inferred) + if result_line is not None: + results.append(result_line) elif predicate == "path": constants = quoted_constants(line) if len(constants) >= 2: From 71eae2ab51723bf9b350150ff5900ab21986c0e5 Mon Sep 17 00:00:00 2001 From: Seoyun Lee Date: Wed, 29 Jul 2026 11:53:28 +0900 Subject: [PATCH 3/5] =?UTF-8?q?fix:=20=EC=A7=88=EC=9D=98=20=EB=B0=98?= =?UTF-8?q?=ED=96=A5=EC=9D=84=20=EC=83=81=EC=88=98=EA=B0=80=20=EA=B3=A0?= =?UTF-8?q?=EC=A0=95=EB=90=9C=20=EC=A0=95=EC=B1=85=20=EC=A7=88=EC=9D=98?= =?UTF-8?q?=EB=A1=9C=20=ED=95=9C=EC=A0=95=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 반향이 모든 정책 결과 줄에 붙어 pred(E, R)? 의 출력 텍스트까지 바뀌었다. 이슈 수용 기준("변수 전용 질의의 기존 출력 불변")이 문자 그대로 깨진다. 앞선 커밋 본문의 "골든 산출물 불변" 은 사실이지만 "출력 불변" 은 거짓이었다 — 골든 KB 의 query.dl 에 정책 질의가 없어 골든만 우연히 무사했을 뿐이다. 반향의 목적은 extent 줄(전체 엔티티 3 rows)과 결과 줄(0 rows)의 모순처럼 보이는 어긋남을 범위로 읽히게 하는 것인데, 그 어긋남은 상수가 고정된 질의에서만 생긴다. 변수 전용 질의는 extent 를 그대로 보고하므로 extent 줄과 어긋날 수 없다. 따라서 인용 상수가 하나라도 있는 질의에만 반향을 붙인다. 행 집합과 바인딩 렌더는 두 shape 모두 변하지 않는다. 상수 고정 질의만 반향이 붙고, 변수 전용 질의는 upstream/main c6d359d 의 렌더와 바이트 단위로 같다 — 그 문자열을 테스트가 리터럴로 고정한다. 업스트림 #326 --- .../unit/test_policy_result_line_rendering.py | 13 +++++++++++++ tools/run_logic_check.py | 18 +++++++++++------- 2 files changed, 24 insertions(+), 7 deletions(-) diff --git a/tests/unit/test_policy_result_line_rendering.py b/tests/unit/test_policy_result_line_rendering.py index 3b4c4460..ada2b7fa 100644 --- a/tests/unit/test_policy_result_line_rendering.py +++ b/tests/unit/test_policy_result_line_rendering.py @@ -50,6 +50,19 @@ def test_wellformed_query_still_produces_a_result(self, monkeypatch): class TestResultLineNamesTheQueryItAnswers: + def test_variable_only_query_text_is_byte_identical_to_before_the_fix(self): + # The fix promised not to change the output of a variable-only query. + # This is the literal upstream/main (c6d359d) rendering of this input, + # so the echo must not reach it: a variable-only query reports the extent, + # which is exactly what the 'Policy evaluation:' line says, so it cannot + # produce the mismatch the echo exists to explain. + line = rlc.policy_result_line(PREDICATE, f"{PREDICATE}(E, R)?", INFERRED) + assert line == ( + "needs_review results: 3 rows; " + "E=Alice, R=low_conf; E=Carol, R=stale; E=Dave, R=no_source" + ) + assert "query:" not in line + def test_result_line_echoes_the_query(self): draft = f'{PREDICATE}("Bob", R)?' line = rlc.policy_result_line(PREDICATE, draft, INFERRED) diff --git a/tools/run_logic_check.py b/tools/run_logic_check.py index ab3f4b0b..dcc22bc0 100644 --- a/tools/run_logic_check.py +++ b/tools/run_logic_check.py @@ -141,12 +141,15 @@ def policy_result_line(predicate: str, line: str, inferred: dict[str, set[tuple[ an answer for a line `validate_query` is reporting as an error in the same report. Emitting nothing leaves the Errors section to speak. - The query is echoed because this line and the "Policy evaluation:" extent - line (": N rows", the count over ALL entities) sit a few lines apart - and now legitimately disagree — 3 rows there, 0 rows here. Naming the query - that produced the 0 is what makes the pair readable as scope rather than - contradiction, and it also tells two queries on the same predicate apart. - The extent line itself is left untouched: it is pinned by + The query is echoed ONLY when a quoted constant is pinned. Such a line and + the "Policy evaluation:" extent line (": N rows", the count over ALL + entities) sit a few lines apart and now legitimately disagree — 3 rows there, + 0 rows here — so naming the query that produced the 0 is what makes the pair + readable as scope rather than contradiction, and it tells two queries on the + same predicate apart. A variable-only query cannot produce that mismatch (it + reports the extent, which is what the extent line says), so it keeps its + original text byte for byte — the query-shape whose output this fix promised + not to change. The extent line itself is left untouched: it is pinned by tests/golden/logic_report.txt, and its section header already says it is the policy evaluation rather than the answer to any one query. """ @@ -162,7 +165,8 @@ def policy_result_line(predicate: str, line: str, inferred: dict[str, set[tuple[ bindings.append(f"{arg}={value}") values.append(", ".join(bindings) if bindings else ", ".join(row)) suffix = "; " + "; ".join(values) if values else "" - return f"{predicate} results (query: {line}): {len(rows)} rows{suffix}" + echo = f" (query: {line})" if any(is_quoted_string(arg) for arg in args) else "" + return f"{predicate} results{echo}: {len(rows)} rows{suffix}" def evaluate_queries(facts: list[dict[str, str]], inferred: dict[str, set[tuple[str, ...]]], policy_query_predicates: set[str]) -> list[str]: From 835aa01d2202b308083fcff030c9d71df0feb8f1 Mon Sep 17 00:00:00 2001 From: Seoyun Lee Date: Wed, 29 Jul 2026 11:55:29 +0900 Subject: [PATCH 4/5] =?UTF-8?q?fix:=20arity=20=EA=B0=80=20=EC=96=B4?= =?UTF-8?q?=EA=B8=8B=EB=82=9C=20=EC=A0=95=EC=B1=85=20=EC=A7=88=EC=9D=98?= =?UTF-8?q?=EB=A5=BC=20=EB=A6=AC=ED=8F=AC=ED=8A=B8=EC=99=80=20=EB=9D=BC?= =?UTF-8?q?=EC=9A=B0=ED=84=B0=20=EB=AA=A8=EB=91=90=20=EB=8B=B5=ED=95=98?= =?UTF-8?q?=EC=A7=80=20=EC=95=8A=EB=8A=94=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 앞 커밋의 `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 --- .../unit/test_policy_result_line_rendering.py | 73 +++++++++++++++---- tools/ask_router.py | 17 ++++- tools/run_logic_check.py | 25 +++++-- 3 files changed, 91 insertions(+), 24 deletions(-) diff --git a/tests/unit/test_policy_result_line_rendering.py b/tests/unit/test_policy_result_line_rendering.py index ada2b7fa..923ed523 100644 --- a/tests/unit/test_policy_result_line_rendering.py +++ b/tests/unit/test_policy_result_line_rendering.py @@ -3,20 +3,28 @@ Two rendering defects that survive the quoted-constant filter: -1. An UNPARSEABLE query (no trailing '?', say) makes ``query_args`` return []. - With no args, no constant is pinned, so the filter passes every row and the - line answers with the predicate's whole extent — for a query the report is - simultaneously rejecting in its own Errors section ('query must end with ?'). - An error plus a fabricated full-extent answer is worse than the error alone. +1. A MALFORMED query still got an answer. No trailing '?' makes ``query_args`` + return [] and ``pred()?`` returns one empty arg — either way no constant is + pinned, the filter passes every row, and the line reports the predicate's + whole extent. Wrong arity (``pred("Alice")?``, ``pred(E, R, "zzz")?``) filters + on whatever constants happen to line up with a column and reports a plausible + but meaningless count. All four shapes are rejected in the report's own Errors + section at the same time; an error plus a fabricated answer is worse than the + error alone. The guard here is the arity test ``validate_query`` already uses, + and ``ask_router.evaluate`` raises on the same shapes so neither path answers. 2. The filtered result line and the "Policy evaluation:" extent line sit a few lines apart in the same report and now legitimately disagree (3 rows there, 0 rows here). Echoing the query is what makes that pair readable as scope - rather than contradiction; the extent line is deliberately left alone (it is - pinned by tests/golden/logic_report.txt). + rather than contradiction. The echo is limited to constant-pinned queries so a + variable-only query keeps its original text; the extent line is deliberately + left alone (it is pinned by tests/golden/logic_report.txt). """ from __future__ import annotations +import pytest + +import ask_router import run_logic_check as rlc PREDICATE = "needs_review" @@ -29,19 +37,27 @@ } -class TestUnparseableQueryEmitsNoResultLine: +MALFORMED = [ + f'{PREDICATE}("Bob", R)', # no trailing '?' — query_args returns [] + PREDICATE, # bare predicate, not an atom at all + f"{PREDICATE}()?", # one empty arg, not zero args + f'{PREDICATE}("Alice")?', # too few + f'{PREDICATE}("Alice", R, "zzz")?', # too many +] + + +class TestMalformedQueryEmitsNoResultLine: def _evaluate(self, monkeypatch, line): monkeypatch.setattr(rlc, "query_lines", lambda: [line]) return rlc.evaluate_queries([], INFERRED, {PREDICATE}) - def test_missing_question_mark_produces_no_result(self, monkeypatch): - assert self._evaluate(monkeypatch, f'{PREDICATE}("Bob", R)') == [] + @pytest.mark.parametrize("line", MALFORMED) + def test_malformed_query_produces_no_result_line(self, monkeypatch, line): + assert self._evaluate(monkeypatch, line) == [] - def test_bare_predicate_produces_no_result(self, monkeypatch): - assert self._evaluate(monkeypatch, PREDICATE) == [] - - def test_unparseable_line_returns_none_not_a_full_extent_string(self, monkeypatch): - assert rlc.policy_result_line(PREDICATE, f'{PREDICATE}("Bob", R)', INFERRED) is None + @pytest.mark.parametrize("line", MALFORMED) + def test_malformed_query_returns_none_not_a_row_count_string(self, line): + assert rlc.policy_result_line(PREDICATE, line, INFERRED) is None def test_wellformed_query_still_produces_a_result(self, monkeypatch): results = self._evaluate(monkeypatch, f'{PREDICATE}("Alice", R)?') @@ -49,6 +65,33 @@ def test_wellformed_query_still_produces_a_result(self, monkeypatch): assert "1 rows" in results[0] +class TestMalformedQueryParity: + """A malformed policy query must get NO answer from either path. + + Before the guard the two paths disagreed outright on the same input: for + ``pred("Bob", R)`` (no '?') the router returned count=3 while the report + returned nothing, and for ``pred(E, R, "zzz")?`` both rendered '0 rows', + which reads as a verified negative for a query classify_query rejects as + BAD_ARITY. The router refuses by raising (cmd_evaluate turns that into error + JSON); the report refuses by emitting no line. Neither invents a count. + """ + + @pytest.mark.parametrize("draft", MALFORMED) + def test_router_refuses_and_report_stays_silent(self, monkeypatch, draft): + monkeypatch.setattr(ask_router, "policy_predicates", lambda program: {PREDICATE}) + monkeypatch.setattr(ask_router, "run_wirelog", lambda: INFERRED) + with pytest.raises(NotImplementedError): + ask_router.evaluate(draft, []) + assert rlc.policy_result_line(PREDICATE, draft, INFERRED) is None + + def test_wellformed_query_is_still_answered_by_both(self, monkeypatch): + monkeypatch.setattr(ask_router, "policy_predicates", lambda program: {PREDICATE}) + monkeypatch.setattr(ask_router, "run_wirelog", lambda: INFERRED) + draft = f'{PREDICATE}("Alice", R)?' + assert ask_router.evaluate(draft, [])["count"] == 1 + assert "1 rows" in rlc.policy_result_line(PREDICATE, draft, INFERRED) + + class TestResultLineNamesTheQueryItAnswers: def test_variable_only_query_text_is_byte_identical_to_before_the_fix(self): # The fix promised not to change the output of a variable-only query. diff --git a/tools/ask_router.py b/tools/ask_router.py index a6c6f0a9..782ae6cb 100644 --- a/tools/ask_router.py +++ b/tools/ask_router.py @@ -464,11 +464,12 @@ def evaluate(draft: str, facts: list[dict[str, str]]) -> dict[str, object]: - path: a fully-quoted query returns the dependency path (or none); a query with a variable returns the reachable (start, target) pairs. - policy predicate: the inferred (entity, reason) rows from the engine, - optionally filtered by a quoted entity argument. + filtered by every quoted constant the query pins, at whatever argument + position it appears (see policy_row_matches). A truly unknown predicate raises NotImplementedError rather than returning 0 rows, so a caller never mistakes an unsupported predicate for a verified - negative. + negative. A malformed count or policy query raises for the same reason. """ predicate = _predicate_of(draft) args = query_args(draft) @@ -533,6 +534,18 @@ def evaluate(draft: str, facts: list[dict[str, str]]) -> dict[str, object]: # verified negative). Catch broad Exception — never BaseException, so # KeyboardInterrupt/SystemExit still propagate — because the engine may # raise non-FactlogError types. + # Guard arity BEFORE evaluating, like the count branch above (#257) and + # for the same reason: with a malformed query no constant lines up with a + # column, so the filter passes rows it cannot have checked — `pred("X")?` + # returned a filtered-looking count and `pred(E, R, "zzz")?` returned 0 + # rows, which renders as a verified negative for a query classify_query + # already rejects as BAD_ARITY. classify_query rejects these too, so the + # render path never reaches here; the `evaluate` subcommand does, and + # cmd_evaluate turns NotImplementedError into a clean error JSON. + # run_logic_check drops the result line on the same shapes, so the report + # and ask agree that a malformed policy query has no answer. + if len(args) != 2: + raise NotImplementedError("policy query must have entity and reason arguments") try: inferred = run_wirelog() except Exception as exc: # noqa: BLE001 — engine/loader raise non-FactlogError too diff --git a/tools/run_logic_check.py b/tools/run_logic_check.py index dcc22bc0..1c11034d 100644 --- a/tools/run_logic_check.py +++ b/tools/run_logic_check.py @@ -133,13 +133,24 @@ def policy_row_matches(args: list[str], row: tuple[str, ...] | list[str]) -> boo def policy_result_line(predicate: str, line: str, inferred: dict[str, set[tuple[str, ...]]]) -> str | None: - """Render one policy query's result, or None when the query is unparseable. + """Render one policy query's result, or None when the query is malformed. - `query_args` returns [] for a line it cannot read (a missing trailing '?', - say). "No args" is not "no constants to honour" — it means the query was - never understood, so answering it with the predicate's whole extent invents - an answer for a line `validate_query` is reporting as an error in the same - report. Emitting nothing leaves the Errors section to speak. + The arity test is the SAME one validate_query applies to a policy query + (entity + reason, i.e. exactly 2 args), so a line the report is rejecting in + its Errors section never also receives an answer here. Three shapes reach + this function malformed, and each used to be answered: + + - unparseable (no trailing '?'): `query_args` returns [], no constant is + pinned, so the filter passes everything -> the whole extent; + - `pred()?` -> one empty arg, likewise unfiltered -> the whole extent; + - `pred("Alice")?` / `pred("Alice", R, "zzz")?` -> wrong arity, filtered by + whatever constants happen to line up -> a plausible but meaningless count. + + "No usable args" is not "no constants to honour" — it means the query was + never understood, so answering it invents an answer for a line the report is + simultaneously calling an error. Emitting nothing leaves the Errors section + to speak. ask_router.evaluate raises NotImplementedError on the same shapes, + so neither path answers a malformed policy query. The query is echoed ONLY when a quoted constant is pinned. Such a line and the "Policy evaluation:" extent line (": N rows", the count over ALL @@ -154,7 +165,7 @@ def policy_result_line(predicate: str, line: str, inferred: dict[str, set[tuple[ policy evaluation rather than the answer to any one query. """ args = query_args(line) - if not args: + if len(args) != 2: return None rows = [row for row in sorted(inferred[predicate]) if policy_row_matches(args, row)] values: list[str] = [] From b2877415a545100ba5b904be6816aa960c70a3a6 Mon Sep 17 00:00:00 2001 From: Seoyun Lee Date: Wed, 29 Jul 2026 11:57:15 +0900 Subject: [PATCH 5/5] =?UTF-8?q?test:=20=ED=95=9C=EC=AA=BD=20=EA=B2=BD?= =?UTF-8?q?=EB=A1=9C=EB=A7=8C=20=EB=B0=94=EB=80=8C=EB=A9=B4=20=EC=A3=BD?= =?UTF-8?q?=EB=8F=84=EB=A1=9D=20=ED=8C=8C=EB=A6=AC=ED=8B=B0=20=ED=85=8C?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=EB=A5=BC=20=EA=B0=95=ED=99=94=ED=95=9C?= =?UTF-8?q?=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- tests/unit/test_policy_query_filter.py | 44 ++++++++++++++++++++++---- tools/ask_router.py | 5 ++- tools/run_logic_check.py | 5 ++- 3 files changed, 46 insertions(+), 8 deletions(-) diff --git a/tests/unit/test_policy_query_filter.py b/tests/unit/test_policy_query_filter.py index f0918abf..afb90df3 100644 --- a/tests/unit/test_policy_query_filter.py +++ b/tests/unit/test_policy_query_filter.py @@ -24,6 +24,7 @@ from __future__ import annotations import re +import unicodedata import pytest @@ -96,12 +97,8 @@ def test_all_variable_query_still_reports_full_extent(self): assert _row_count(line) == 3 assert "E=Alice, R=low_conf" in line - def test_empty_row_dropped_when_a_constant_is_pinned(self): - # A 0-arity row cannot satisfy a pinned constant, so it is filtered out; - # an all-variable query keeps reporting it, as before. - inferred = {PREDICATE: {(), ("Alice", "low_conf")}} - assert _report_rows(f'{PREDICATE}("Alice", R)?', inferred) == 1 - assert _report_rows(f"{PREDICATE}(E, R)?", inferred) == 2 + # The 0-arity row case lives in TestReportRouterParity: asserting it on the + # report alone let a router that dropped its short-row guard survive. class TestReportRouterParity: @@ -133,3 +130,38 @@ def test_report_and_router_agree_on_the_verified_row_count(self, monkeypatch, dr assert report_rows == expected assert router_rows == expected assert report_rows == router_rows + + def test_zero_arity_row_is_dropped_by_both_paths(self, monkeypatch): + # The cases above are all 2-column rows, so neither path's short-row + # guard is exercised by them: a router that dropped the guard kept + # passing. A 0-arity row cannot satisfy a pinned constant, on EITHER path. + inferred = {PREDICATE: {(), ("Alice", "low_conf")}} + pinned = f'{PREDICATE}("Alice", R)?' + assert _report_rows(pinned, inferred) == 1 + assert _router_rows(monkeypatch, pinned, inferred) == 1 + # Non-vacuous: the empty row IS in the extent both paths start from, so + # the 1 above is a filter result, not an absent row. + variables = f"{PREDICATE}(E, R)?" + assert _report_rows(variables, inferred) == 2 + assert _router_rows(monkeypatch, variables, inferred) == 2 + + def test_nfd_stored_entity_does_not_meet_an_nfc_query_on_either_path(self, monkeypatch): + # Pins CURRENT behaviour, not desired behaviour: both paths compare raw + # values, so an NFD-stored entity is invisible to an NFC-typed constant + # and the query reports 0 rows — which reads as a verified negative. That + # is a known gap (see #213 and policy_row_matches' docstring); what this + # test forbids is FIXING IT ON ONE PATH ONLY, which would put the report + # and ask back into disagreement. The cases above are ASCII-only, so a + # router that folded to NFC on its own kept passing them. + nfd = unicodedata.normalize("NFD", "박수영") + nfc = unicodedata.normalize("NFC", "박수영") + assert nfd != nfc + inferred = {PREDICATE: {(nfd, "stale")}} + nfc_query = f'{PREDICATE}("{nfc}", R)?' + assert _report_rows(nfc_query, inferred) == 0 + assert _router_rows(monkeypatch, nfc_query, inferred) == 0 + # Non-vacuous: the row is reachable — an NFD-typed constant finds it on + # both paths, so the 0 above is the folding gap and not an empty extent. + nfd_query = f'{PREDICATE}("{nfd}", R)?' + assert _report_rows(nfd_query, inferred) == 1 + assert _router_rows(monkeypatch, nfd_query, inferred) == 1 diff --git a/tools/ask_router.py b/tools/ask_router.py index 782ae6cb..9035780c 100644 --- a/tools/ask_router.py +++ b/tools/ask_router.py @@ -447,7 +447,10 @@ def policy_row_matches(args: list[str], row: tuple[str, ...] | list[str]) -> boo (same body, module-specific docstring). The natural home is common.py alongside the other query-parsing helpers, but hoisting it there is a wider change than this fix needs; the report/router parity test fails if the two - copies ever drift. + copies ever drift. Two of its cases carry that load — the 0-arity row and the + NFD-stored/NFC-queried entity. Every other case is a 2-column ASCII row, + which a copy that lost the short-row guard, or that folded to NFC on its own, + still gets right; do not delete those two. """ for index, arg in enumerate(args): if not is_quoted_string(arg): diff --git a/tools/run_logic_check.py b/tools/run_logic_check.py index 1c11034d..98839196 100644 --- a/tools/run_logic_check.py +++ b/tools/run_logic_check.py @@ -122,7 +122,10 @@ def policy_row_matches(args: list[str], row: tuple[str, ...] | list[str]) -> boo (same body, module-specific docstring). The natural home is common.py alongside the other query-parsing helpers, but hoisting it there is a wider change than this fix needs; the report/router parity test fails if the two - copies ever drift. + copies ever drift. Two of its cases carry that load — the 0-arity row and the + NFD-stored/NFC-queried entity. Every other case is a 2-column ASCII row, + which a copy that lost the short-row guard, or that folded to NFC on its own, + still gets right; do not delete those two. """ for index, arg in enumerate(args): if not is_quoted_string(arg):