diff --git a/leetcode_coach/leetcode_coach/DOCS-clients.md b/leetcode_coach/leetcode_coach/DOCS-clients.md index a6b726f..ac60064 100644 --- a/leetcode_coach/leetcode_coach/DOCS-clients.md +++ b/leetcode_coach/leetcode_coach/DOCS-clients.md @@ -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 `` marker is logged and stripped. Every step lands in `~/.local/share/leetcode-coach/sessions/-.jsonl` diff --git a/leetcode_coach/leetcode_coach/leak.py b/leetcode_coach/leetcode_coach/leak.py index 506e774..4d5e4cd 100644 --- a/leetcode_coach/leetcode_coach/leak.py +++ b/leetcode_coach/leetcode_coach/leak.py @@ -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": diff --git a/leetcode_coach/leetcode_coach/phase.py b/leetcode_coach/leetcode_coach/phase.py index ec06187..2b0b766 100644 --- a/leetcode_coach/leetcode_coach/phase.py +++ b/leetcode_coach/leetcode_coach/phase.py @@ -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) diff --git a/leetcode_coach/leetcode_coach/stop_gate.py b/leetcode_coach/leetcode_coach/stop_gate.py index dae3b8f..79ae749 100644 --- a/leetcode_coach/leetcode_coach/stop_gate.py +++ b/leetcode_coach/leetcode_coach/stop_gate.py @@ -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) diff --git a/leetcode_coach/leetcode_coach/tests/test_phase.py b/leetcode_coach/leetcode_coach/tests/test_phase.py index a9fc021..f07bdfb 100644 --- a/leetcode_coach/leetcode_coach/tests/test_phase.py +++ b/leetcode_coach/leetcode_coach/tests/test_phase.py @@ -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( diff --git a/leetcode_coach/leetcode_coach/tests/test_stop_gate_leak.py b/leetcode_coach/leetcode_coach/tests/test_stop_gate_leak.py index 132d1cb..df3f85b 100644 --- a/leetcode_coach/leetcode_coach/tests/test_stop_gate_leak.py +++ b/leetcode_coach/leetcode_coach/tests/test_stop_gate_leak.py @@ -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(