Leak bounce exempts only names kuhy typed; hide property meanings that name an entry

The fetched statement carries LeetCode's topic tags and site hints ("Hash
Table", "Two Pointers"), so exempting it pre-cleared the answer. Some real
toolbox meanings illustrate with the operation (self-inverse: "a ^ x ^ x"),
so the incomplete-list note shows those properties by name only.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PjvF5DZG9JduSBYKxqyysi
This commit is contained in:
Krzysztof Rudnicki 2026-10-23 15:54:45 +02:00
parent ebf7ca624e
commit 6343499ed1
6 changed files with 49 additions and 18 deletions

View File

@ -31,7 +31,8 @@ Both REPL paths run `phase.PhaseGate` on every block kuhy sends:
event carries `req_incomplete: true`).
- Until the first `code` event, a reply naming a toolbox entry or alias is
bounced (`leak.py`: the Stop hook on the Claude path, `local_coach` on the
local one), except names kuhy or the fetched statement already used.
local one), except names kuhy already typed (not the statement: its tags
and hints name the answer).
- Each reply's `<!-- rung:Hn -->` marker is logged and stripped.
Every step lands in `~/.local/share/leetcode-coach/sessions/<date>-<slug>.jsonl`

View File

@ -5,9 +5,11 @@ its name or any alias, e.g. "XOR", "^", "hash map" -- hands over step 3 of the
method (searching the toolbox by properties). Shared by the Stop hook
(`stop_gate`) and the offline coach (`local_coach`), so both judge alike.
An entry kuhy (or the problem statement) already named is free for the whole
session, under every alias: after "is it XOR?" the honest answer has to be
able to say XOR, or the rewrite can never pass and the gate loops.
An entry kuhy already named is free for the whole session, under every
alias: after "is it XOR?" the honest answer has to be able to say XOR, or
the rewrite can never pass and the gate loops. Only kuhy's own words count:
the fetched statement carries LeetCode's topic tags and hints ("Hash
Table", "Sliding Window"), so exempting it would pre-clear the answer.
Matching is case-insensitive on whole tokens. ``\b`` does not work next to a
symbol alias like ``^``, so the boundary is "no word character either side":

View File

@ -81,6 +81,16 @@ def req_state(events: list[dict[str, Any]]) -> ReqState:
return ReqState(accepted, not accepted and tries >= MAX_LISTS, tries)
def _property_line(prop: str, box: toolbox.Toolbox) -> str:
"""`prop: meaning`, or just `prop` when the meaning names an entry.
Some meanings illustrate with the operation itself (self-inverse:
"a ^ x ^ x = a"); shown to kuhy that is the answer, not a property.
"""
meaning = box.properties[prop]
return prop if leak.leaked_names(meaning, [], box) else f"{prop}: {meaning}"
def _req_note(result: req_check.Check, state: ReqState, box: toolbox.Toolbox) -> str:
if result.verdict == "pass":
return "[req list accepted -- code is open]"
@ -89,7 +99,7 @@ def _req_note(result: req_check.Check, state: ReqState, box: toolbox.Toolbox) ->
if state.forced:
missing = ", ".join(result.missing)
return f"[req list still incomplete ({missing}) -- code accepted anyway]"
props = "\n".join(f" - {p}: {box.properties[p]}" for p in result.missing)
props = "\n".join(f" - {_property_line(p, box)}" for p in result.missing)
return (
f"[req list incomplete -- it does not yet say these properties:\n{props}\n"
f" revise with req: ({state.tries} of {MAX_LISTS})]"
@ -187,5 +197,4 @@ class PhaseGate:
"""Toolbox entries `reply` names too early (the local path's bounce)."""
if any(t.strip() == "reveal" for t in user_texts) or self.code_seen():
return []
allowed = [*user_texts, self.statement()]
return leak.leaked_names(reply, allowed, self.toolbox)
return leak.leaked_names(reply, user_texts, self.toolbox)

View File

@ -1,7 +1,8 @@
"""Stop hook: in a /leetcode session, refuse a reply that hands kuhy a line of code.
Before kuhy's first code it also bounces a reply that names a toolbox entry
(`leak`), except an entry kuhy or the fetched problem already named.
(`leak`), except an entry kuhy already named. Not the fetched problem:
its topic tags and site hints say "Hash Table", "Two Pointers".
The prose rule ("never write a line for their code") held in one haiku
role-play out of two, so it is adjudicated here instead. A reply is blocked
@ -61,8 +62,7 @@ def verdict(raw: str, reply: str) -> str | None:
if any(u.strip() == "reveal" for u in users):
return None
if not _code_seen(users):
quoted = [gate_detector.allowed_text(entries)]
named = leak.leaked_names(reply, quoted, toolbox.load_safe())
named = leak.leaked_names(reply, users, toolbox.load_safe())
if named:
return local_prompt.GATE_REASON_LEAK.format(names=", ".join(named))
idents = gate_detector.starter_identifiers(entries)

View File

@ -203,13 +203,27 @@ def test_leaks_only_before_code_and_never_what_kuhy_typed(
assert gate.leaks(reply, []) == []
def test_a_statement_naming_an_entry_lets_the_coach_restate_it(
tmp_path: Path,
) -> None:
def test_the_statement_does_not_free_a_name(tmp_path: Path) -> None:
gate = phase.PhaseGate(
tmp_path / "l.jsonl", toolbox.load_safe(), lambda: "Use XOR."
tmp_path / "l.jsonl", toolbox.load_safe(), lambda: "Topics: XOR."
)
assert gate.leaks("XOR here", []) == []
assert gate.leaks("XOR here", []) == ["XOR"]
def test_a_meaning_that_names_an_entry_is_not_shown(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
box = toolbox.Toolbox(
(toolbox.Entry("XOR", ("^",), ("self-inverse",)),),
{"self-inverse": "a ^ x ^ x = a", "constant-space": "memory stays flat"},
)
missing = req_check.Check("incomplete", ("self-inverse", "constant-space"))
monkeypatch.setattr(req_check, "check", _Checks(missing))
gate = phase.PhaseGate(tmp_path / "l.jsonl", box, lambda: "s")
_, note = gate.screen("req: one pass")
assert " - self-inverse\n" in note
assert " - constant-space: memory stays flat\n" in note
assert "^" not in note
def test_open_exports_the_log_path_for_the_hooks(

View File

@ -48,14 +48,19 @@ def test_the_bounce_reason_does_not_whitelist_itself() -> None:
assert stop_gate.verdict(_raw(SLASH_LOAD, feedback), LEAKY) is not None
def test_names_in_the_fetched_statement_may_be_restated() -> None:
def test_the_fetched_statement_whitelists_nothing() -> None:
"""Its topic tags and site hints name the answer ("Topics: Hash Table")."""
tool = {
"type": "user",
"message": {
"content": [{"type": "tool_result", "content": "Solve it with XOR."}]
"content": [
{"type": "tool_result", "content": "Topics: Hash Table. Use XOR."}
]
},
}
assert stop_gate.verdict(_raw(SLASH_LOAD, tool), LEAKY) is None
raw = _raw(SLASH_LOAD, tool)
assert stop_gate.verdict(raw, LEAKY) is not None
assert stop_gate.verdict(raw, "Would a hash map help?") is not None
def test_the_session_log_lifts_the_bounce_once_code_exists(