From 28c243d06658b00c67ca3bbb47557afe43bf681e Mon Sep 17 00:00:00 2001 From: Krzysztof kuhy Rudnicki Date: Fri, 23 Oct 2026 15:39:52 +0200 Subject: [PATCH] Coach toolbox, clusters, cold re-tests and property cards (TODO-coach 9-12) - toolbox.json: 20 entries over a fixed 40-property vocabulary; no entry's property set nests in another's, so every property card has one answer. - clusters.json: decision 10's six clusters, ids plus slugs. - progress.py (stdlib only): solved = code + postmortem, re-tests due at +3/+10 days, cluster progress, all derived from the session logs. - leetcode due / --cluster / --retest (cluster_cli.py): due re-tests of the cluster first, then the next problem with no code event, skipping LeetCode-Accepted ones (live check fails open). A cold re-test logs `retest` only once the session reached a post-mortem. - Post-mortem cards are property -> operation only, generated by toolbox_cards (shared slug `toolbox`); `add` appends an entry only after kuhy confirms its properties. The local client accepts {"toolbox": [...]}. - _coach_log publishes retests_due and clusters from the coach. - requirements-sync treats sibling repo packages as first-party. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01PjvF5DZG9JduSBYKxqyysi --- leetcode_coach/leetcode_coach/SKILL.md | 87 +++--- leetcode_coach/leetcode_coach/cluster_cli.py | 149 +++++++++++ leetcode_coach/leetcode_coach/clusters.json | 40 +++ .../leetcode_coach/leetcode_repl.py | 19 +- leetcode_coach/leetcode_coach/local_cards.py | 11 + leetcode_coach/leetcode_coach/local_prompt.py | 7 +- leetcode_coach/leetcode_coach/progress.py | 217 +++++++++++++++ .../leetcode_coach/tests/test_cluster_cli.py | 248 ++++++++++++++++++ .../leetcode_coach/tests/test_local_coach.py | 15 ++ .../leetcode_coach/tests/test_progress.py | 197 ++++++++++++++ .../leetcode_coach/tests/test_toolbox.py | 171 ++++++++++++ leetcode_coach/leetcode_coach/toolbox.json | 146 +++++++++++ leetcode_coach/leetcode_coach/toolbox.py | 22 ++ .../leetcode_coach/toolbox_cards.py | 153 +++++++++++ 14 files changed, 1430 insertions(+), 52 deletions(-) create mode 100644 leetcode_coach/leetcode_coach/cluster_cli.py create mode 100644 leetcode_coach/leetcode_coach/clusters.json create mode 100644 leetcode_coach/leetcode_coach/progress.py create mode 100644 leetcode_coach/leetcode_coach/tests/test_cluster_cli.py create mode 100644 leetcode_coach/leetcode_coach/tests/test_progress.py create mode 100644 leetcode_coach/leetcode_coach/tests/test_toolbox.py create mode 100644 leetcode_coach/leetcode_coach/toolbox.json create mode 100644 leetcode_coach/leetcode_coach/toolbox.py create mode 100644 leetcode_coach/leetcode_coach/toolbox_cards.py diff --git a/leetcode_coach/leetcode_coach/SKILL.md b/leetcode_coach/leetcode_coach/SKILL.md index e52abe4..b28f0ec 100644 --- a/leetcode_coach/leetcode_coach/SKILL.md +++ b/leetcode_coach/leetcode_coach/SKILL.md @@ -7,8 +7,8 @@ description: > every reply ends with something for kuhy to try. Never prints a solution unless kuhy types `reveal`. Use on /leetcode [what you are stuck on] AND whenever kuhy pastes a leetcode.com/problems/ URL without SOLVE: - (hook leetcode_url_userprompt.sh tells you to). Ends with a post-mortem: 1-6 one-line Anki cards (.apkg) - about the confusions, never the solution. + (hook leetcode_url_userprompt.sh tells you to). Ends with a post-mortem: property -> operation + Anki cards generated from the toolbox, never the solution. argument-hint: " [what you're stuck on]" --- @@ -149,52 +149,40 @@ opened with ("cancel stops the function at a random place") and ask what is wrong with it now. A passing submission does not retire a misconception; only a corrected sentence from them does. -## 5b. Post-mortem: Anki cards +## 5b. Post-mortem: requirement list, then property cards -After the loop is closed, in the same reply, build the cards: +After the loop is closed, in the same reply: -1. List every turn where kuhy was wrong or stuck. Each is one candidate - card; the original confusion is always one. Drop the ones that were - about *this problem* rather than a reusable mechanism. Test: would the - card still be worth reviewing if kuhy never saw this problem again? - A card restating the rule kuhy reasoned out in chat fails it. - - Knowing *what* to do but failing to write it (every failed code - attempt) earns a **write-it card**: the front names a *sibling* task, - not this problem ("TS: write makeCounter()" for a once() session; count - digits for a sum-of-digits one), the back is complete code — never - `{ ... }`. Conceptual cards alone test theory kuhy already had: the - 2026-09-25 audit found 7 of 8 decks with zero code cards. - - Ask for the thing itself. Yes/no and either/or fronts ("nest or - separate loops?" → "Nest.") are coin flips; the builder rejects them, - and two questions on one front. - - Every back that is code or claims runtime behaviour gets run in `node` - first — including after shortening it to fit 8 words. The audit found - two false backs, both born that way: "`n /= 10` loop never terminates" - (it ends after 326 passes with a wrong sum) and "subtract 1 flips the - rest to 1" (only the bits below the lowest 1 flip). -2. Pipe the cards to the builder in ONE Bash call (no file, no Write tool): +1. Ask kuhy for the requirement list as *properties* (what must vanish, what + must survive, how much space), not code or an operation name; then, for + every hint rung kuhy saw, which requirement that hint handed over. +2. Cards are **property -> operation only** (TODO-coach.md decision 11): one + per toolbox entry the solution relied on, generated from `toolbox.json` + (front = its properties, back = its name), in ONE Bash call: ```bash - ~/.claude/skills/leetcode/make_cards.py - <<'EOF' - {"slug": "", "cards": [{"front": "...", "back": "..."}]} - EOF + python3 -m leetcode_coach.toolbox_cards cards "" ["" ...] \ + | ~/.claude/skills/leetcode/make_cards.py - ``` - - front: one question, ≤20 words, prefixed with the language - ("JS: cancel a pending setTimeout?") - - back: ≤8 words, ideally one token (`clearTimeout(id)`). The builder - rejects longer backs — shorten, do not argue with it. - - 1–6 cards. Never a card whose back is the solution to the problem. - On "cards rejected", fix the named card and rerun. -3. Relay the script's stdout verbatim — as markdown, NOT inside a code + Names or aliases, 1-6 per call. They all share the slug `toolbox`, so + never `--replace` here: it would delete every other entry's card. +3. An operation kuhy named that is **not** in the toolbox: propose its + properties from the `toolbox.json` vocabulary and ask kuhy to confirm or + correct them. Only after kuhy confirms, append it (`append_entry`) -- + never on your own judgement, never with unconfirmed properties: + ```bash + python3 -m leetcode_coach.toolbox_cards add "" --alias "" \ + --property "" --property "" + ``` + Refused because the set nests with another entry's? Ask kuhy which + property tells the two apart. Then build its card as in step 2. +4. Relay the builder's stdout verbatim -- as markdown, NOT inside a code fence (the table must render): the table and the delivery line. If Anki desktop is open (AnkiConnect) the cards are already in deck - "LeetCode" and a sync to AnkiWeb was requested — nothing more to do. + "LeetCode" and a sync to AnkiWeb was requested -- nothing more to do. Otherwise it wrote an `.apkg` and printed a bare `anki ''` line to - run (kuhy pasted the old `import: anki …` label as a command, twice); do - not launch anki yourself. Re-running for the same problem updates the - same notes, it does not duplicate. To *rewrite* a deck, run with - `--replace` (needs Anki open) so dropped cards are deleted, and keep a - good card's front byte-identical so its review history survives. - `--dry-run` validates and prints the table without delivering. + run (kuhy pasted the old `import: anki ...` label as a command, twice); do + not launch anki yourself. Re-running updates the same notes, it does not + duplicate. "cards rejected" is a generator bug: report it, do not edit. ## 5c. After the exit: "how would I find this on my own?" @@ -203,9 +191,20 @@ did most of it"), no verdict on kuhy's ability, no "everyone memorises it". Say bluntly who supplied which step, never reframe kuhy's own diagnosis of a mistake as the problem's fault, then have kuhy write the requirement list (what vanishes, what survives, what that demands of the operation) and name -the requirement a hint gave them. That method gets a card: if the deck is -built, rebuild it (`--replace`, or rerun the builder). 2026-10-20: three -replies of comfort before the method appeared. +the requirement a hint gave them. The method *is* that list, and its card is +the property card of the entry the list points to (§5b step 2): build it if +the deck does not have it yet. 2026-10-20: three replies of comfort before +the method appeared. + +## 5d. Cold re-test + +`[cold re-test]` in the arguments (`leetcode --retest `, due +3 and ++10 days after a solve) means kuhy solved this before; you have no history, +so do not ask about the old code. In the opening, say plainly: **do NOT +submit on LeetCode** -- a resubmission mints a gate credit. Then coach as +usual, requirement list first, and judge by running the statement's examples +locally with `node` (section 4). The normal exit and post-mortem (5, 5b) +mark the re-test done. ## 6. `reveal` — the only override diff --git a/leetcode_coach/leetcode_coach/cluster_cli.py b/leetcode_coach/leetcode_coach/cluster_cli.py new file mode 100644 index 0000000..667e6a9 --- /dev/null +++ b/leetcode_coach/leetcode_coach/cluster_cli.py @@ -0,0 +1,149 @@ +"""`leetcode due`, `leetcode --cluster ` and `leetcode --retest `. + +TODO-coach.md decisions 10 and 12, on top of `progress` (which derives +everything from the session logs): + +* ``due`` lists the cold re-tests that are due (+3 and +10 days after a solve). +* ``--cluster `` serves that cluster's due re-test first, else its next + problem with no ``code`` event, skipping problems LeetCode already reports + Accepted. The live check fails OPEN: if LeetCode cannot be asked, the + problem is served rather than skipped. +* ``--retest `` starts a cold session: a fresh ``claude -p`` session + with no history, whose opening tells the coach it is a re-test judged in + ``node`` and that kuhy must NOT submit -- a resubmission is a new + submission id, and the harvester mints a gate credit for every one. + A ``retest`` event is logged only if the session reached a post-mortem, so + quitting early does not burn the re-test. + +``--dry-run`` says what would be served without starting a session. The +cluster name reaches the session writer through ``$LEETCODE_COACH_CLUSTER``. +Its prints are its interface (T201 exemption in pyproject.toml). +""" + +from __future__ import annotations + +from collections.abc import Callable +import logging +import os +import sys + +from leetcode_coach import progress +from leetcode_guard._live_solved import check_solved +from leetcode_guard._settings import build_client + +_logger = logging.getLogger(__name__) + +COMMANDS = frozenset({"due", "--cluster", "--retest"}) +USAGE = ( + "usage: leetcode due | --cluster [--dry-run] | --retest [--dry-run]" +) +STAGES = len(progress.RETEST_OFFSETS) +COLD_RETEST_TAG = "[cold re-test]" +COLD_RETEST = ( + f"{COLD_RETEST_TAG} kuhy solved this problem before. This is a cold " + "re-test: fresh session, no hints or code from last time, requirement " + "list first as usual. Judge the solution by running the statement's " + "examples locally with node, not on LeetCode. Tell kuhy: do NOT submit " + "on LeetCode, a resubmission mints a gate credit." +) + +Start = Callable[[list[str]], int] + + +def accepted_on_leetcode(slug: str) -> bool: + """Whether LeetCode confirms an Accepted submission; False if it cannot say.""" + try: + evidence = check_solved(build_client().post, [slug]) + except Exception: # pylint: disable=broad-exception-caught + _logger.exception("live solved-check for %s raised; serving it", slug) + return False + return slug in evidence.solved + + +def _due() -> int: + rows = progress.due_retests() + if not rows: + print("no re-tests due") + return 0 + for row in rows: + print( + f"{row['due']} {row['slug']} re-test {row['stage']}/{STAGES} " + f"({row['cluster'] or 'no cluster'}) -> leetcode --retest {row['slug']}" + ) + return 0 + + +def _retest(slug: str, start: Start, *, dry: bool) -> int: + past = progress.history(slug) + if past.solved is None: + print( + f"{slug} is not solved yet (no code + post-mortem logged)", file=sys.stderr + ) + return 1 + if past.retests >= STAGES: + print(f"both re-tests of {slug} are done; practise it with `leetcode {slug}`") + return 0 + stage = past.retests + 1 + print(f"cold re-test {stage}/{STAGES}: {slug} (solved {past.solved})") + if dry: + return 0 + cluster = progress.cluster_of(slug) + if cluster is None: + os.environ.pop(progress.CLUSTER_ENV, None) + else: + os.environ[progress.CLUSTER_ENV] = cluster + before = progress.count_events(slug, "postmortem") + code = start([slug, COLD_RETEST]) + if progress.count_events(slug, "postmortem") > before: + event = {"event": "retest", "stage": stage, "cluster": cluster} + progress.append_event(slug, event) + print(f"re-test {stage}/{STAGES} logged for {slug}") + else: + print( + f"re-test not logged: the session ended without a post-mortem; " + f"rerun `leetcode --retest {slug}`" + ) + return code + + +def _cluster( + name: str, start: Start, accepted: Callable[[str], bool], *, dry: bool +) -> int: + clusters = progress.load_clusters() + if name not in clusters: + known = ", ".join(clusters) + print(f"unknown cluster {name!r}; known: {known}", file=sys.stderr) + return 1 + due = [row for row in progress.due_retests() if row["cluster"] == name] + if due: + print(f"re-test due: {due[0]['slug']} (due {due[0]['due']})") + return _retest(due[0]["slug"], start, dry=dry) + slug = progress.next_new(name, None, accepted) + if slug is None: + print(f"{name}: every problem is started or accepted on LeetCode") + return 0 + number = next(p["id"] for p in clusters[name] if p["slug"] == slug) + print(f"next in {name}: {slug} (#{number})") + if dry: + return 0 + os.environ[progress.CLUSTER_ENV] = name + return start([slug]) + + +def main( + argv: list[str], start: Start, accepted: Callable[[str], bool] | None = None +) -> int: + """Dispatch `due`, `--cluster ` and `--retest `.""" + dry = "--dry-run" in argv + args = [a for a in argv if a != "--dry-run"] + match args: + case ["due"]: + return _due() + case ["--cluster", name]: + check = accepted_on_leetcode if accepted is None else accepted + return _cluster(name, start, check, dry=dry) + case ["--retest", slug]: + return _retest(slug, start, dry=dry) + case _: + print(USAGE, file=sys.stderr) + return 1 diff --git a/leetcode_coach/leetcode_coach/clusters.json b/leetcode_coach/leetcode_coach/clusters.json new file mode 100644 index 0000000..1d3dc45 --- /dev/null +++ b/leetcode_coach/leetcode_coach/clusters.json @@ -0,0 +1,40 @@ +{ + "clusters": { + "bit-cancellation": [ + {"id": 268, "slug": "missing-number"}, + {"id": 260, "slug": "single-number-iii"}, + {"id": 169, "slug": "majority-element"}, + {"id": 137, "slug": "single-number-ii"} + ], + "two-pointers": [ + {"id": 167, "slug": "two-sum-ii-input-array-is-sorted"}, + {"id": 15, "slug": "3sum"}, + {"id": 11, "slug": "container-with-most-water"}, + {"id": 42, "slug": "trapping-rain-water"} + ], + "sliding-window": [ + {"id": 3, "slug": "longest-substring-without-repeating-characters"}, + {"id": 424, "slug": "longest-repeating-character-replacement"}, + {"id": 567, "slug": "permutation-in-string"}, + {"id": 76, "slug": "minimum-window-substring"} + ], + "prefix-sum-hash": [ + {"id": 560, "slug": "subarray-sum-equals-k"}, + {"id": 523, "slug": "continuous-subarray-sum"}, + {"id": 974, "slug": "subarray-sums-divisible-by-k"}, + {"id": 525, "slug": "contiguous-array"} + ], + "monotonic-stack": [ + {"id": 739, "slug": "daily-temperatures"}, + {"id": 496, "slug": "next-greater-element-i"}, + {"id": 503, "slug": "next-greater-element-ii"}, + {"id": 84, "slug": "largest-rectangle-in-histogram"} + ], + "intervals": [ + {"id": 56, "slug": "merge-intervals"}, + {"id": 57, "slug": "insert-interval"}, + {"id": 435, "slug": "non-overlapping-intervals"}, + {"id": 452, "slug": "minimum-number-of-arrows-to-burst-balloons"} + ] + } +} diff --git a/leetcode_coach/leetcode_coach/leetcode_repl.py b/leetcode_coach/leetcode_coach/leetcode_repl.py index 0e17d16..869dc26 100755 --- a/leetcode_coach/leetcode_coach/leetcode_repl.py +++ b/leetcode_coach/leetcode_coach/leetcode_repl.py @@ -21,6 +21,7 @@ its prints ARE the session (T201 exemption in pyproject.toml). from __future__ import annotations +import functools import json import logging import os @@ -31,7 +32,7 @@ import subprocess import sys from typing import Any -from leetcode_coach import local_session +from leetcode_coach import cluster_cli, local_session # Named, not __name__: under `-m` this module is `__main__`, which would escape # the package logger's NullHandler and print the warning to the session. @@ -127,6 +128,14 @@ def run_claude(model: str, problem: list[str]) -> int: ] +def start_session(problem: list[str], *, local: bool) -> int: + """One coaching session for ` [stuck on...]`.""" + if local: + opener = local_session.opening_message(problem[1:]) + return local_session.run(problem[0], [], opener, read_block) + return run_claude(os.environ.get("LEETCODE_MODEL", "sonnet"), problem) + + def main(argv: list[str]) -> int: """Parse `[--local] [stuck on...]` and run a session.""" local = "--local" in argv @@ -135,11 +144,11 @@ def main(argv: list[str]) -> int: print("Usage: leetcode [--local] [what you are stuck on...]") print(" LEETCODE_MODEL=opus leetcode ... to use a stronger model") print(" --local coach on the local Ollama model (free, offline)") + print(" leetcode due | --cluster | --retest (--dry-run)") return 0 - if local: - opener = local_session.opening_message(args[1:]) - return local_session.run(args[0], [], opener, read_block) - return run_claude(os.environ.get("LEETCODE_MODEL", "sonnet"), args) + if args[0] in cluster_cli.COMMANDS: + return cluster_cli.main(args, functools.partial(start_session, local=local)) + return start_session(args, local=local) if __name__ == "__main__": diff --git a/leetcode_coach/leetcode_coach/local_cards.py b/leetcode_coach/leetcode_coach/local_cards.py index ef90157..54b1ee4 100644 --- a/leetcode_coach/leetcode_coach/local_cards.py +++ b/leetcode_coach/leetcode_coach/local_cards.py @@ -14,6 +14,8 @@ import subprocess import sys from typing import TYPE_CHECKING, Any +from leetcode_coach import toolbox_cards + if TYPE_CHECKING: from collections.abc import Callable @@ -43,6 +45,15 @@ def deliver_cards( """Pipe the cards to the card builder; (accepted, its stdout+stderr).""" if "invalid" in payload: return False, f"cards block is not a JSON object: {payload['invalid']!r}" + if "toolbox" in payload: + # Property -> operation cards (decision 11) come from toolbox.json, + # so the local model names entries instead of typing fronts. + try: + cards = toolbox_cards.cards_for(list(payload["toolbox"])) + except KeyError as exc: + _LOG.warning("cards block names an unknown entry: %s", exc) + return False, str(exc.args[0]) + payload = {"slug": toolbox_cards.SLUG, "cards": cards} proc = run( [sys.executable, "-m", "leetcode_coach.make_cards", "-"], input=json.dumps(payload), diff --git a/leetcode_coach/leetcode_coach/local_prompt.py b/leetcode_coach/leetcode_coach/local_prompt.py index 8c9a013..c0f0d06 100644 --- a/leetcode_coach/leetcode_coach/local_prompt.py +++ b/leetcode_coach/leetcode_coach/local_prompt.py @@ -45,11 +45,12 @@ LOCAL_NOTES = """ that typing `reveal` ends the exercise. - Never repeat a previous reply. Answer what kuhy just wrote. - For the post-mortem cards (section 5b), do NOT print a Bash command. Emit one - fenced block tagged `cards` holding the JSON, e.g. + fenced block tagged `cards` naming the toolbox entries, e.g. ```cards - {"slug": "", "cards": [{"front": "JS: ...?", "back": "..."}]} + {"toolbox": ["", ""]} ``` - The client builds the deck and shows the result. + The client builds the property cards and shows the result. Never append to + the toolbox from this client: tell kuhy to do it in a Claude session. # The problem (already fetched) diff --git a/leetcode_coach/leetcode_coach/progress.py b/leetcode_coach/leetcode_coach/progress.py new file mode 100644 index 0000000..dc391b1 --- /dev/null +++ b/leetcode_coach/leetcode_coach/progress.py @@ -0,0 +1,217 @@ +"""Cluster progress and the cold re-test schedule, derived from the session logs. + +TODO-coach.md decisions 10 and 12. Nothing here keeps state of its own: the +JSONL session logs (``-.jsonl``, decision 8) are the only +record, so progress survives anything that survives the logs. + +* A problem is **started** once it has a ``code`` event, and **solved** on the + first day by which it has both a ``code`` and a ``postmortem`` event. A + later re-test session logs its own code and post-mortem; that never moves + the solve day, so it cannot reset the schedule. +* Re-tests fall due ``RETEST_OFFSETS`` days after the solve day (+3, +10). + Each ``retest`` event completes one stage. + +Days come from the file name, like ``leetcode_guard._coach_log``: the ``ts`` +field's format belongs to the session writer. Stdlib only, because the status +snapshot imports this every minute on the system python. +""" + +from __future__ import annotations + +from dataclasses import dataclass +import datetime as dt +import json +import logging +import os +from pathlib import Path +import re +from typing import TYPE_CHECKING, Any, Final + +if TYPE_CHECKING: + from collections.abc import Callable + +_LOG: Final = logging.getLogger(__name__) + +SESSIONS_ENV: Final = "LEETCODE_COACH_SESSIONS_DIR" +CLUSTER_ENV: Final = "LEETCODE_COACH_CLUSTER" +"""Set for the coached session; the session writer stamps it as ``cluster``.""" +DEFAULT_SESSIONS_DIR: Final = ( + Path.home() / ".local" / "share" / "leetcode-coach" / "sessions" +) +CLUSTERS_PATH: Final = Path(__file__).with_name("clusters.json") +RETEST_OFFSETS: Final = (3, 10) +_DATED: Final = re.compile(r"(?P\d{4}-\d{2}-\d{2})-(?P.+)") + +Event = dict[str, Any] + + +@dataclass(frozen=True) +class History: + """What the logs say about one problem.""" + + started: bool + solved: dt.date | None + retests: int + + +def sessions_dir() -> Path: + """Where the session logs live (``$LEETCODE_COACH_SESSIONS_DIR`` overrides).""" + override = os.environ.get(SESSIONS_ENV) + return Path(override) if override else DEFAULT_SESSIONS_DIR + + +def local_today() -> dt.date: + """Today in local time: a re-test is due by kuhy's calendar, not UTC's.""" + return dt.datetime.now(tz=dt.UTC).astimezone().date() + + +def load_clusters(path: Path | None = None) -> dict[str, list[dict[str, Any]]]: + """``{"": [{"id": 268, "slug": "missing-number"}, ...]}``, in order.""" + source = CLUSTERS_PATH if path is None else path + clusters: dict[str, list[dict[str, Any]]] = json.loads( + source.read_text(encoding="utf-8") + )["clusters"] + return clusters + + +def cluster_of(slug: str) -> str | None: + """The cluster a problem belongs to, if any.""" + for name, problems in load_clusters().items(): + if any(p["slug"] == slug for p in problems): + return name + return None + + +def _dated(path: Path) -> tuple[dt.date, str] | None: + """``2026-10-23-missing-number.jsonl`` -> (date, slug); None if undated.""" + match = _DATED.fullmatch(path.stem) + if match is None: + return None + try: + return dt.date.fromisoformat(match["day"]), match["slug"] + except ValueError: + _LOG.warning("coach session %s has an impossible date; skipped", path) + return None + + +def _parse(path: Path, line: str) -> Event | None: + """One log line as an event; None for anything that is not one.""" + try: + event = json.loads(line) + except ValueError as exc: + # A torn line is a writer that died mid-write: worth its own warning. + _LOG.warning("coach session %s: a line is not JSON: %s", path, exc) + return None + if isinstance(event, dict) and isinstance(event.get("event"), str): + return event + return None + + +def _events(path: Path) -> list[Event]: + """The file's events in order; unreadable files and junk lines are skipped.""" + try: + lines = path.read_text(encoding="utf-8").splitlines() + except (OSError, UnicodeDecodeError) as exc: + _LOG.warning("coach session %s is unreadable: %s", path, exc) + return [] + parsed = (_parse(path, line) for line in lines if line.strip()) + return [event for event in parsed if event is not None] + + +def _read(directory: Path | None) -> dict[str, list[tuple[dt.date, Event]]]: + """Every slug's events across all its files, oldest file first.""" + folder = sessions_dir() if directory is None else directory + by_slug: dict[str, list[tuple[dt.date, Event]]] = {} + if not folder.is_dir(): + return by_slug + for path in sorted(folder.glob("*.jsonl")): + if (dated := _dated(path)) is not None: + day, slug = dated + by_slug.setdefault(slug, []).extend((day, e) for e in _events(path)) + return by_slug + + +def _history(entries: list[tuple[dt.date, Event]]) -> History: + seen: set[str] = set() + solved: dt.date | None = None + for day, event in entries: + seen.add(event["event"]) + if solved is None and {"code", "postmortem"} <= seen: + solved = day + retests = sum(event["event"] == "retest" for _day, event in entries) + return History(started="code" in seen, solved=solved, retests=retests) + + +def history(slug: str, directory: Path | None = None) -> History: + """Started / solved day / re-tests done for one problem.""" + return _history(_read(directory).get(slug, [])) + + +def count_events(slug: str, kind: str, directory: Path | None = None) -> int: + """How many ``kind`` events the problem has across all its files.""" + return sum(event["event"] == kind for _d, event in _read(directory).get(slug, [])) + + +def due_retests( + directory: Path | None = None, today: dt.date | None = None +) -> list[dict[str, Any]]: + """Re-tests due by ``today``, oldest due date first.""" + day = local_today() if today is None else today + rows: list[dict[str, Any]] = [] + for slug, entries in _read(directory).items(): + past = _history(entries) + if past.solved is None or past.retests >= len(RETEST_OFFSETS): + continue + due = past.solved + dt.timedelta(days=RETEST_OFFSETS[past.retests]) + if due <= day: + rows.append( + { + "slug": slug, + "cluster": cluster_of(slug), + "stage": past.retests + 1, + "due": due.isoformat(), + "solved": past.solved.isoformat(), + } + ) + return sorted(rows, key=lambda row: (row["due"], row["slug"])) + + +def cluster_progress(directory: Path | None = None) -> dict[str, dict[str, Any]]: + """Per cluster: problems total / started / solved, and the next unstarted one.""" + logs = _read(directory) + summary: dict[str, dict[str, Any]] = {} + for name, problems in load_clusters().items(): + pasts = [_history(logs.get(p["slug"], [])) for p in problems] + fresh = [ + p["slug"] for p, h in zip(problems, pasts, strict=True) if not h.started + ] + summary[name] = { + "total": len(problems), + "started": sum(h.started for h in pasts), + "solved": sum(h.solved is not None for h in pasts), + "next": fresh[0] if fresh else None, + } + return summary + + +def next_new( + name: str, directory: Path | None, accepted: Callable[[str], bool] +) -> str | None: + """The cluster's first problem with no ``code`` event that is not Accepted.""" + logs = _read(directory) + for problem in load_clusters()[name]: + slug = str(problem["slug"]) + if not _history(logs.get(slug, [])).started and not accepted(slug): + return slug + return None + + +def append_event(slug: str, event: Event) -> Path: + """Append one event (stamped with ``ts``) to today's session file.""" + folder = sessions_dir() + folder.mkdir(parents=True, exist_ok=True) + path = folder / f"{local_today().isoformat()}-{slug}.jsonl" + stamp = dt.datetime.now(tz=dt.UTC).astimezone().isoformat(timespec="seconds") + with path.open("a", encoding="utf-8") as fh: + fh.write(json.dumps({**event, "ts": stamp}, ensure_ascii=False) + "\n") + return path diff --git a/leetcode_coach/leetcode_coach/tests/test_cluster_cli.py b/leetcode_coach/leetcode_coach/tests/test_cluster_cli.py new file mode 100644 index 0000000..b7a4354 --- /dev/null +++ b/leetcode_coach/leetcode_coach/tests/test_cluster_cli.py @@ -0,0 +1,248 @@ +"""Tests for `leetcode due`, `leetcode --cluster ` and `leetcode --retest `. + +The prints are the interface (T201 exemption), so these assert on stdout. +""" + +from __future__ import annotations + +import datetime as dt +import os +from typing import TYPE_CHECKING + +import pytest + +from leetcode_coach import cluster_cli, leetcode_repl, local_session, progress +from leetcode_coach.tests.test_progress import log, solve +from leetcode_guard import _leetcode, _live_solved + +if TYPE_CHECKING: + from pathlib import Path + +TODAY = progress.local_today() + + +@pytest.fixture +def sessions(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> Path: + monkeypatch.setenv(progress.SESSIONS_ENV, str(tmp_path)) + monkeypatch.delenv(progress.CLUSTER_ENV, raising=False) + return tmp_path + + +class Starter: + """Stands in for the coaching session; may log a post-mortem like kuhy would.""" + + def __init__(self, directory: Path, *, postmortem: bool = True) -> None: + self.directory, self.postmortem = directory, postmortem + self.problems: list[list[str]] = [] + self.cluster: str | None = None + + def __call__(self, problem: list[str]) -> int: + self.problems.append(problem) + self.cluster = os.environ.get(progress.CLUSTER_ENV) + if self.postmortem: + log(self.directory, TODAY, problem[0], {"event": "postmortem"}) + return 0 + + +def never(_slug: str) -> bool: + return False + + +def test_due_lists_nothing(sessions: Path, capsys: pytest.CaptureFixture[str]) -> None: + assert cluster_cli.main(["due"], Starter(sessions)) == 0 + assert capsys.readouterr().out == "no re-tests due\n" + + +def test_due_lists_a_three_day_retest( + sessions: Path, capsys: pytest.CaptureFixture[str] +) -> None: + solve(sessions, TODAY - dt.timedelta(days=3), "missing-number") + assert cluster_cli.main(["due"], Starter(sessions)) == 0 + out = capsys.readouterr().out + assert "missing-number" in out + assert "re-test 1/2" in out + assert "bit-cancellation" in out + assert "leetcode --retest missing-number" in out + + +def test_cluster_serves_the_next_unstarted_problem( + sessions: Path, capsys: pytest.CaptureFixture[str] +) -> None: + solve(sessions, TODAY, "missing-number") + start = Starter(sessions, postmortem=False) + assert cluster_cli.main(["--cluster", "bit-cancellation"], start, never) == 0 + assert start.problems == [["single-number-iii"]] + assert start.cluster == "bit-cancellation" + assert "next in bit-cancellation: single-number-iii (#260)" in ( + capsys.readouterr().out + ) + + +def test_cluster_skips_a_problem_accepted_on_leetcode(sessions: Path) -> None: + start = Starter(sessions, postmortem=False) + accepted = {"missing-number"}.__contains__ + assert cluster_cli.main(["--cluster", "bit-cancellation"], start, accepted) == 0 + assert start.problems == [["single-number-iii"]] + + +def test_cluster_serves_a_due_retest_first( + sessions: Path, capsys: pytest.CaptureFixture[str] +) -> None: + solve(sessions, TODAY - dt.timedelta(days=3), "single-number-iii") + solve(sessions, TODAY - dt.timedelta(days=3), "two-sum") + start = Starter(sessions) + assert cluster_cli.main(["--cluster", "bit-cancellation"], start, never) == 0 + assert start.problems[0][0] == "single-number-iii" + assert start.problems[0][1].startswith(cluster_cli.COLD_RETEST_TAG) + assert "re-test due: single-number-iii" in capsys.readouterr().out + + +def test_dry_run_starts_nothing( + sessions: Path, capsys: pytest.CaptureFixture[str] +) -> None: + solve(sessions, TODAY - dt.timedelta(days=3), "missing-number") + start = Starter(sessions) + assert ( + cluster_cli.main(["--cluster", "two-pointers", "--dry-run"], start, never) == 0 + ) + assert cluster_cli.main(["--cluster", "bit-cancellation", "--dry-run"], start) == 0 + assert cluster_cli.main(["--retest", "missing-number", "--dry-run"], start) == 0 + assert start.problems == [] + out = capsys.readouterr().out + assert "next in two-pointers: two-sum-ii-input-array-is-sorted (#167)" in out + assert "re-test due: missing-number" in out + assert "cold re-test 1/2: missing-number" in out + + +def test_a_finished_cluster_says_so( + sessions: Path, capsys: pytest.CaptureFixture[str] +) -> None: + start = Starter(sessions) + assert cluster_cli.main(["--cluster", "intervals"], start, lambda _s: True) == 0 + assert start.problems == [] + assert "intervals: every problem is started or accepted" in capsys.readouterr().out + + +def test_unknown_or_missing_cluster_fails( + sessions: Path, capsys: pytest.CaptureFixture[str] +) -> None: + assert cluster_cli.main(["--cluster", "graphs"], Starter(sessions)) == 1 + assert cluster_cli.main(["--cluster"], Starter(sessions)) == 1 + assert cluster_cli.main(["--retest"], Starter(sessions)) == 1 + err = capsys.readouterr().err + assert "unknown cluster 'graphs'" in err + assert "bit-cancellation" in err + assert "usage" in err + + +def test_retest_logs_an_event_after_a_postmortem( + sessions: Path, capsys: pytest.CaptureFixture[str] +) -> None: + solve(sessions, TODAY - dt.timedelta(days=4), "missing-number") + start = Starter(sessions) + assert cluster_cli.main(["--retest", "missing-number"], start) == 0 + assert start.problems[0][0] == "missing-number" + assert "do NOT submit" in start.problems[0][1] + assert "node" in start.problems[0][1] + assert start.cluster == "bit-cancellation" + assert progress.history("missing-number", sessions).retests == 1 + assert "re-test 1/2 logged" in capsys.readouterr().out + assert progress.due_retests(sessions) == [] + + +def test_retest_without_a_postmortem_is_not_logged( + sessions: Path, capsys: pytest.CaptureFixture[str] +) -> None: + solve(sessions, TODAY - dt.timedelta(days=4), "two-sum") + start = Starter(sessions, postmortem=False) + assert cluster_cli.main(["--retest", "two-sum"], start) == 0 + assert start.cluster is None + assert progress.history("two-sum", sessions).retests == 0 + assert "re-test not logged" in capsys.readouterr().out + + +def test_retest_of_an_unsolved_problem_is_refused( + sessions: Path, capsys: pytest.CaptureFixture[str] +) -> None: + start = Starter(sessions) + assert cluster_cli.main(["--retest", "two-sum"], start) == 1 + assert start.problems == [] + assert "not solved yet" in capsys.readouterr().err + + +def test_live_check_fails_open(monkeypatch: pytest.MonkeyPatch) -> None: + def boom() -> object: + raise OSError + + monkeypatch.setattr(cluster_cli, "build_client", boom) + assert cluster_cli.accepted_on_leetcode("missing-number") is False + + +def test_live_check_reports_an_accepted_problem( + monkeypatch: pytest.MonkeyPatch, +) -> None: + seen: list[object] = [] + + class Client: + post = "post" + + def check(post: object, slugs: list[str]) -> _live_solved.LiveSolved: + seen.extend([post, *slugs]) + return _live_solved.LiveSolved(solved=frozenset({"missing-number"})) + + monkeypatch.setattr(cluster_cli, "build_client", Client) + monkeypatch.setattr(cluster_cli, "check_solved", check) + assert cluster_cli.accepted_on_leetcode("missing-number") is True + assert cluster_cli.accepted_on_leetcode("two-sum") is False + assert seen[:2] == ["post", "missing-number"] + + +def test_a_silent_live_check_serves_the_problem( + monkeypatch: pytest.MonkeyPatch, +) -> None: + def silent(_q: str, _v: dict[str, object]) -> _leetcode.GraphQLResult: + return _leetcode.GraphQLResult(data=None, transport_error="offline") + + class Client: + post = staticmethod(silent) + + monkeypatch.setattr(cluster_cli, "build_client", Client) + assert cluster_cli.accepted_on_leetcode("missing-number") is False + + +def test_both_retests_done_means_nothing_more( + sessions: Path, capsys: pytest.CaptureFixture[str] +) -> None: + solve(sessions, TODAY - dt.timedelta(days=20), "missing-number") + log(sessions, TODAY, "missing-number", {"event": "retest"}, {"event": "retest"}) + start = Starter(sessions) + assert cluster_cli.main(["--retest", "missing-number"], start) == 0 + assert start.problems == [] + assert "both re-tests of missing-number are done" in capsys.readouterr().out + assert cluster_cli.main(["due", "extra"], start) == 1 + + +def test_repl_main_dispatches_cluster_commands( + sessions: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: + assert leetcode_repl.main(["due"]) == 0 + assert capsys.readouterr().out == "no re-tests due\n" + started: list[tuple[str, list[str]]] = [] + + def fake_claude(model: str, problem: list[str]) -> int: + started.append((model, problem)) + return 0 + + handed: list[str] = [] + + def fake_local(slug: str, *_rest: object) -> int: + handed.append(slug) + return 0 + + monkeypatch.setattr(leetcode_repl, "run_claude", fake_claude) + monkeypatch.setattr(local_session, "run", fake_local) + monkeypatch.setattr(cluster_cli, "accepted_on_leetcode", never) + assert leetcode_repl.main(["--cluster", "intervals"]) == 0 + assert started == [("sonnet", ["merge-intervals"])] + assert leetcode_repl.main(["--local", "--cluster", "intervals"]) == 0 + assert handed == ["merge-intervals"] diff --git a/leetcode_coach/leetcode_coach/tests/test_local_coach.py b/leetcode_coach/leetcode_coach/tests/test_local_coach.py index 13a1b08..227e99c 100644 --- a/leetcode_coach/leetcode_coach/tests/test_local_coach.py +++ b/leetcode_coach/leetcode_coach/tests/test_local_coach.py @@ -99,6 +99,21 @@ def test_deliver_cards_pipes_to_make_cards() -> None: assert local_cards.deliver_cards({"invalid": "x"})[0] is False +def test_a_toolbox_block_becomes_property_cards() -> None: + seen: dict[str, Any] = {} + + def run(cmd: list[str], **kw: Any) -> subprocess.CompletedProcess[str]: + seen.update(input=json.loads(kw["input"])) + return completed(0, "| table |") + + assert local_cards.deliver_cards({"toolbox": ["xor"]}, run=run)[0] + assert seen["input"]["slug"] == "toolbox" + assert seen["input"]["cards"][0]["back"] == "XOR" + ok, out = local_cards.deliver_cards({"toolbox": ["segment tree"]}, run=run) + assert not ok + assert "not in the toolbox" in out + + def test_rejected_cards_are_fed_back_once_fixed( monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] ) -> None: diff --git a/leetcode_coach/leetcode_coach/tests/test_progress.py b/leetcode_coach/leetcode_coach/tests/test_progress.py new file mode 100644 index 0000000..5017602 --- /dev/null +++ b/leetcode_coach/leetcode_coach/tests/test_progress.py @@ -0,0 +1,197 @@ +"""Tests for cluster progress and the +3/+10 day re-test schedule (decisions 10, 12). + +Progress is derived from the session logs alone: a problem is *started* once it +has a ``code`` event and *solved* once it has a ``code`` and a ``postmortem``. +""" + +from __future__ import annotations + +import datetime as dt +import json +import logging +from typing import TYPE_CHECKING + +from leetcode_coach import progress + +if TYPE_CHECKING: + from pathlib import Path + + import pytest + +DAY0 = dt.date(2026, 10, 20) + +DECISION_10 = { + "bit-cancellation": [268, 260, 169, 137], + "two-pointers": [167, 15, 11, 42], + "sliding-window": [3, 424, 567, 76], + "prefix-sum-hash": [560, 523, 974, 525], + "monotonic-stack": [739, 496, 503, 84], + "intervals": [56, 57, 435, 452], +} + + +def log(directory: Path, day: dt.date, slug: str, *events: object) -> Path: + """Append events (dicts, or raw strings for torn lines) to a session file.""" + directory.mkdir(parents=True, exist_ok=True) + path = directory / f"{day.isoformat()}-{slug}.jsonl" + with path.open("a", encoding="utf-8") as fh: + for event in events: + fh.write((event if isinstance(event, str) else json.dumps(event)) + "\n") + return path + + +def solve(directory: Path, day: dt.date, slug: str) -> None: + log(directory, day, slug, {"event": "code", "ts": "x"}, {"event": "postmortem"}) + + +def test_clusters_json_is_decision_10_verbatim() -> None: + clusters = progress.load_clusters() + assert {name: [p["id"] for p in ps] for name, ps in clusters.items()} == ( + DECISION_10 + ) + assert list(clusters) == list(DECISION_10) + assert clusters["bit-cancellation"][0]["slug"] == "missing-number" + + +def test_cluster_of_names_the_owning_cluster() -> None: + assert progress.cluster_of("single-number-iii") == "bit-cancellation" + assert progress.cluster_of("two-sum") is None + + +def test_sessions_dir_is_overridable( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + monkeypatch.setenv(progress.SESSIONS_ENV, str(tmp_path)) + assert progress.sessions_dir() == tmp_path + monkeypatch.delenv(progress.SESSIONS_ENV) + assert progress.sessions_dir().parts[-3:] == ( + "share", + "leetcode-coach", + "sessions", + ) + + +def test_a_solve_is_due_three_days_later_not_two(tmp_path: Path) -> None: + solve(tmp_path, DAY0, "missing-number") + assert progress.due_retests(tmp_path, today=DAY0 + dt.timedelta(days=2)) == [] + assert progress.due_retests(tmp_path, today=DAY0 + dt.timedelta(days=3)) == [ + { + "slug": "missing-number", + "cluster": "bit-cancellation", + "stage": 1, + "due": "2026-10-23", + "solved": "2026-10-20", + } + ] + + +def test_code_without_postmortem_is_not_solved(tmp_path: Path) -> None: + log(tmp_path, DAY0, "two-sum", {"event": "code"}, {"event": "rung", "rung": "H1"}) + assert progress.due_retests(tmp_path, today=DAY0 + dt.timedelta(days=30)) == [] + + +def test_the_second_retest_waits_for_day_ten_and_then_none(tmp_path: Path) -> None: + solve(tmp_path, DAY0, "two-sum") + later = DAY0 + dt.timedelta(days=4) + log(tmp_path, later, "two-sum", {"event": "code"}, {"event": "postmortem"}) + log(tmp_path, later, "two-sum", {"event": "retest", "stage": 1}) + assert progress.due_retests(tmp_path, today=DAY0 + dt.timedelta(days=9)) == [] + due = progress.due_retests(tmp_path, today=DAY0 + dt.timedelta(days=10)) + assert [(r["stage"], r["due"], r["solved"], r["cluster"]) for r in due] == [ + (2, "2026-10-30", "2026-10-20", None) + ] + log(tmp_path, DAY0 + dt.timedelta(days=11), "two-sum", {"event": "retest"}) + assert progress.due_retests(tmp_path, today=DAY0 + dt.timedelta(days=99)) == [] + + +def test_solve_spread_over_two_days_dates_from_the_postmortem(tmp_path: Path) -> None: + log(tmp_path, DAY0, "3sum", {"event": "code"}) + log(tmp_path, DAY0 + dt.timedelta(days=1), "3sum", {"event": "postmortem"}) + assert progress.history("3sum", tmp_path).solved == DAY0 + dt.timedelta(days=1) + + +def test_due_rows_are_ordered_by_due_date(tmp_path: Path) -> None: + solve(tmp_path, DAY0 + dt.timedelta(days=1), "b-problem") + solve(tmp_path, DAY0, "a-problem") + due = progress.due_retests(tmp_path, today=DAY0 + dt.timedelta(days=5)) + assert [r["slug"] for r in due] == ["a-problem", "b-problem"] + + +def test_junk_is_skipped_with_a_warning( + tmp_path: Path, caplog: pytest.LogCaptureFixture +) -> None: + solve(tmp_path, DAY0, "missing-number") + log(tmp_path, DAY0, "missing-number", "{torn", "[1]", "", {"no": "event"}) + (tmp_path / "notes.jsonl").write_text("{}\n", encoding="utf-8") + (tmp_path / "2026-13-40-bad-date.jsonl").write_text("{}\n", encoding="utf-8") + (tmp_path / "2026-10-20-broken.jsonl").write_bytes(b"\xff\xfe") + with caplog.at_level(logging.WARNING): + due = progress.due_retests(tmp_path, today=DAY0 + dt.timedelta(days=3)) + assert [r["slug"] for r in due] == ["missing-number"] + assert "not JSON" in caplog.text + assert "unreadable" in caplog.text + + +def test_a_missing_directory_has_no_history(tmp_path: Path) -> None: + assert progress.due_retests(tmp_path / "absent", today=DAY0) == [] + assert progress.history("x", tmp_path / "absent") == progress.History( + started=False, solved=None, retests=0 + ) + + +def test_default_today_and_directory_come_from_the_environment( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + monkeypatch.setenv(progress.SESSIONS_ENV, str(tmp_path)) + solve(tmp_path, progress.local_today() - dt.timedelta(days=3), "missing-number") + assert [r["slug"] for r in progress.due_retests()] == ["missing-number"] + assert progress.cluster_progress()["bit-cancellation"]["solved"] == 1 + + +def test_cluster_progress_counts_started_and_solved(tmp_path: Path) -> None: + solve(tmp_path, DAY0, "missing-number") + log(tmp_path, DAY0, "single-number-iii", {"event": "code"}) + for slug in ("merge-intervals", "insert-interval", "non-overlapping-intervals"): + solve(tmp_path, DAY0, slug) + solve(tmp_path, DAY0, "minimum-number-of-arrows-to-burst-balloons") + clusters = progress.cluster_progress(tmp_path) + assert clusters["bit-cancellation"] == { + "total": 4, + "started": 2, + "solved": 1, + "next": "majority-element", + } + assert clusters["intervals"] == { + "total": 4, + "started": 4, + "solved": 4, + "next": None, + } + assert clusters["two-pointers"]["next"] == "two-sum-ii-input-array-is-sorted" + + +def test_next_new_skips_accepted_problems(tmp_path: Path) -> None: + solve(tmp_path, DAY0, "missing-number") + asked: list[str] = [] + + def accepted(slug: str) -> bool: + asked.append(slug) + return slug == "single-number-iii" + + assert progress.next_new("bit-cancellation", tmp_path, accepted) == ( + "majority-element" + ) + assert asked == ["single-number-iii", "majority-element"] + assert progress.next_new("bit-cancellation", tmp_path, lambda _s: True) is None + + +def test_append_event_writes_todays_file( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + monkeypatch.setenv(progress.SESSIONS_ENV, str(tmp_path / "new")) + path = progress.append_event("two-sum", {"event": "retest", "stage": 1}) + assert path.name == f"{progress.local_today().isoformat()}-two-sum.jsonl" + event = json.loads(path.read_text(encoding="utf-8")) + assert event["event"] == "retest" + assert dt.datetime.fromisoformat(event["ts"]).tzinfo is not None + assert progress.count_events("two-sum", "retest", tmp_path / "new") == 1 diff --git a/leetcode_coach/leetcode_coach/tests/test_toolbox.py b/leetcode_coach/leetcode_coach/tests/test_toolbox.py new file mode 100644 index 0000000..399f7ad --- /dev/null +++ b/leetcode_coach/leetcode_coach/tests/test_toolbox.py @@ -0,0 +1,171 @@ +"""Tests for the toolbox (decision 9) and its property cards (decision 11). + +The card front lists an entry's properties, so two entries whose property sets +nest would give a card with two right answers; the shape test rules that out. +""" + +from __future__ import annotations + +import io +import json +import shutil +from typing import TYPE_CHECKING + +import pytest + +from leetcode_coach import card_rules, toolbox, toolbox_cards + +if TYPE_CHECKING: + from pathlib import Path + + +@pytest.fixture +def box(tmp_path: Path) -> Path: + """A private copy of the shipped toolbox, safe to append to.""" + copy = tmp_path / "toolbox.json" + shutil.copy(toolbox.TOOLBOX_PATH, copy) + return copy + + +def test_every_property_is_in_the_vocabulary_and_explained() -> None: + data = toolbox.load() + vocab = data["properties"] + assert all(isinstance(m, str) and m.strip() for m in vocab.values()) + for entry in data["entries"]: + assert entry["properties"], entry["name"] + assert set(entry["properties"]) <= set(vocab), entry["name"] + used = {p for e in data["entries"] for p in e["properties"]} + assert used == set(vocab) + + +def test_names_and_aliases_are_unique() -> None: + names = [ + n.casefold() + for e in toolbox.load()["entries"] + for n in [e["name"], *e["aliases"]] + ] + assert len(names) == len(set(names)) + + +def test_no_entry_property_set_nests_inside_another() -> None: + entries = toolbox.load()["entries"] + assert len(entries) >= 20 + for a in entries: + for b in entries: + if a is not b: + assert not set(a["properties"]) <= set(b["properties"]), ( + a["name"], + b["name"], + ) + + +@pytest.mark.parametrize( + "name", + [ + "XOR", + "per-bit counts mod k", + "Boyer-Moore majority vote", + "two pointers", + "sliding window", + "prefix sums", + "prefix sum + hash map", + "monotonic stack", + "sort by start + merge", + "sort by end + greedy pick", + "heap", + ], +) +def test_the_seeded_clusters_are_covered(name: str) -> None: + assert toolbox_cards.find(toolbox.load(), name)["name"] == name + + +def test_every_entry_makes_a_valid_property_card() -> None: + entries = toolbox.load()["entries"] + cards = [toolbox_cards.entry_card(e) for e in entries] + for start in range(0, len(cards), card_rules.MAX_CARDS): + chunk = cards[start : start + card_rules.MAX_CARDS] + assert card_rules.validate(toolbox_cards.SLUG, chunk) == [] + assert cards[0] == { + "front": "Toolbox: " + ", ".join(entries[0]["properties"]) + "?", + "back": entries[0]["name"], + } + + +def test_find_matches_aliases_case_insensitively() -> None: + assert toolbox_cards.find(toolbox.load(), " hash TABLE ")["name"] == "hash map" + with pytest.raises(KeyError, match="not in the toolbox"): + toolbox_cards.find(toolbox.load(), "segment tree") + + +def test_append_entry_appends_only_when_called(box: Path) -> None: + before = box.read_text(encoding="utf-8") + toolbox_cards.cards_for(["XOR"], box) + assert box.read_text(encoding="utf-8") == before + entry = toolbox_cards.append_entry( + "trie", ["prefix tree"], ["O(1) lookup", "partial order"], path=box + ) + data = toolbox.load(box) + assert ( + data["entries"][-1] + == entry + == { + "name": "trie", + "aliases": ["prefix tree"], + "properties": ["O(1) lookup", "partial order"], + } + ) + assert box.read_text(encoding="utf-8").endswith("\n") + + +@pytest.mark.parametrize( + ("name", "aliases", "properties", "error"), + [ + ("", [], ["greedy"], "empty name"), + ("trie", [], [], "no properties"), + ("trie", [], ["fast"], "unknown properties: fast"), + ("Hash Map", [], ["greedy", "O(1) lookup"], "already in the toolbox"), + ("trie", ["^"], ["greedy", "O(1) lookup"], "already in the toolbox"), + ("trie", [], ["greedy"], "nests with 'sort by end"), + ("trie", [], ["O(1) lookup", "O(1) lookup"], "listed twice"), + ], +) +def test_append_entry_refuses_bad_entries( + box: Path, name: str, aliases: list[str], properties: list[str], error: str +) -> None: + before = box.read_text(encoding="utf-8") + with pytest.raises(ValueError, match=error): + toolbox_cards.append_entry(name, aliases, properties, path=box) + assert box.read_text(encoding="utf-8") == before + + +def test_cli_cards_prints_builder_input(capsys: pytest.CaptureFixture[str]) -> None: + assert toolbox_cards.main(["cards", "xor", "heap"]) == 0 + spec = json.loads(capsys.readouterr().out) + assert spec["slug"] == "toolbox" + assert [c["back"] for c in spec["cards"]] == ["XOR", "heap"] + + +def test_cli_cards_unknown_name_fails(capsys: pytest.CaptureFixture[str]) -> None: + assert toolbox_cards.main(["cards", "segment tree"]) == 1 + assert "not in the toolbox" in capsys.readouterr().err + + +def test_cli_add_appends_and_reports( + box: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: + monkeypatch.setattr(toolbox, "TOOLBOX_PATH", box) + args = ["add", "trie", "--alias", "prefix tree", "--property", "O(1) lookup"] + assert toolbox_cards.main([*args, "--property", "partial order"]) == 0 + assert "added 'trie'" in capsys.readouterr().out + assert toolbox.load(box)["entries"][-1]["name"] == "trie" + assert toolbox_cards.main(["add", "trie2", "--property", "nope"]) == 1 + assert "unknown properties" in capsys.readouterr().err + + +def test_cli_without_a_command_prints_usage( + monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: + monkeypatch.setattr("sys.stdin", io.StringIO("")) + with pytest.raises(SystemExit): + toolbox_cards.main([]) + assert "usage" in capsys.readouterr().err diff --git a/leetcode_coach/leetcode_coach/toolbox.json b/leetcode_coach/leetcode_coach/toolbox.json new file mode 100644 index 0000000..7d7c6f9 --- /dev/null +++ b/leetcode_coach/leetcode_coach/toolbox.json @@ -0,0 +1,146 @@ +{ + "properties": { + "self-inverse": "applying the same value twice undoes it: a ^ x ^ x = a", + "invertible": "every step has a known undo: a + x - x = a", + "commutative": "operand order does not matter: a + b = b + a", + "associative": "grouping does not matter, so a running fold works: (a + b) + c = a + (b + c)", + "identity element": "a neutral value e with a op e = a: 0 for + ^ gcd, -Infinity for max", + "idempotent": "combining a value with itself changes nothing: max(a, a) = a", + "selective": "the result is always one of the operands: max(a, b) is a or b", + "divides its inputs": "the result divides every operand, so folding more values can only shrink it", + "works per bit": "each bit position is computed independently of the others", + "carries between bits": "bit positions interact: a carry moves into the next position", + "cancels any k-fold repeat": "a value that occurs exactly k times leaves no trace, for any chosen k", + "isolates one bit": "keeps a single set bit and clears the rest: x & -x keeps the lowest one", + "O(1) extra space": "a fixed number of variables besides the input, however large the input", + "single pass": "reads the input once, front to back", + "O(1) lookup": "asks 'seen x?' or 'what is stored for x?' in (expected) constant time", + "stores a value per key": "remembers something per key (a count, an index), not only that it was seen", + "presence only": "remembers whether a key was seen and nothing else", + "arbitrary keys": "keys can be any value: strings, huge or negative numbers", + "bounded key range": "keys come from a small known range (26 letters, 0..n), so an array indexed by key works", + "two moving indexes": "two indexes that each move in one direction only, so the scan is O(n)", + "from both ends": "the two indexes start at opposite ends and move toward each other", + "window moves right": "both indexes move left to right; the range between them is the current window", + "monotone condition": "only correct when validity changes once as a range grows (no negatives in a sum)", + "needs sorted input": "only correct once the input is sorted", + "O(n log n) time": "costs about n log n comparisons, more than one linear pass", + "makes equal values adjacent": "afterwards equal (and close) values sit next to each other", + "O(log n) per query": "each question halves the remaining range, so it costs about log n steps", + "range query in O(1)": "after O(n) setup, any contiguous range's total is one subtraction", + "needs an inverse": "only works over an operation that can be undone (+ with -, ^ with ^); max has none", + "keeps every prefix": "stores all n prefix values, so any (l, r) range can be asked later", + "handles negatives": "stays correct when values can be negative (a sliding window does not)", + "needs a majority": "only correct when one value occurs more than n/2 times", + "last-in-first-out": "the most recently added element is the first one taken out", + "next-greater lookup": "for every element, finds the nearest later element that is larger (flip for smaller)", + "amortized O(n)": "each element is added and removed at most once, so the total work is linear", + "overlaps become adjacent": "after sorting by start, every interval that overlaps the current group comes right after it", + "earliest end first": "choosing the interval that ends first leaves the most room for the rest", + "greedy": "commits to the locally best choice and never revisits it", + "O(log n) push and pop-min": "insert and remove-the-smallest each cost about log n; flip the comparison for largest", + "partial order": "only the top element is guaranteed to be in place; the rest is not sorted" + }, + "entries": [ + { + "name": "XOR", + "aliases": ["^", "exclusive or", "bitwise xor"], + "properties": ["self-inverse", "invertible", "commutative", "associative", "identity element", "works per bit"] + }, + { + "name": "sum / difference", + "aliases": ["running sum", "subtract to undo", "Gauss sum formula"], + "properties": ["invertible", "commutative", "associative", "identity element", "carries between bits"] + }, + { + "name": "min / max", + "aliases": ["Math.max", "Math.min", "running max", "running min"], + "properties": ["commutative", "associative", "identity element", "idempotent", "selective"] + }, + { + "name": "gcd", + "aliases": ["greatest common divisor", "Euclid's algorithm", "Euclidean algorithm"], + "properties": ["commutative", "associative", "identity element", "idempotent", "divides its inputs"] + }, + { + "name": "per-bit counts mod k", + "aliases": ["count each bit mod k", "bit counting mod 3", "ones and twos masks"], + "properties": ["works per bit", "cancels any k-fold repeat", "O(1) extra space"] + }, + { + "name": "lowest set bit", + "aliases": ["x & -x", "rightmost set bit", "isolate lowest bit"], + "properties": ["isolates one bit"] + }, + { + "name": "hash map", + "aliases": ["hashmap", "hash table", "new Map", "dictionary"], + "properties": ["O(1) lookup", "stores a value per key", "arbitrary keys"] + }, + { + "name": "hash set", + "aliases": ["hashset", "new Set", "seen set"], + "properties": ["O(1) lookup", "presence only", "arbitrary keys"] + }, + { + "name": "counting array", + "aliases": ["frequency array", "count array", "array of 26 counts"], + "properties": ["O(1) lookup", "stores a value per key", "bounded key range"] + }, + { + "name": "two pointers", + "aliases": ["left and right pointers", "opposite-end pointers"], + "properties": ["two moving indexes", "from both ends", "O(1) extra space"] + }, + { + "name": "sliding window", + "aliases": ["expanding window", "shrinking window", "variable-size window"], + "properties": ["two moving indexes", "window moves right", "monotone condition"] + }, + { + "name": "sorting", + "aliases": ["sort first", "Array.prototype.sort", ".sort("], + "properties": ["O(n log n) time", "makes equal values adjacent"] + }, + { + "name": "binary search", + "aliases": ["bisect", "lower bound", "halving search"], + "properties": ["needs sorted input", "O(log n) per query"] + }, + { + "name": "prefix sums", + "aliases": ["prefix sum array", "cumulative sum", "running totals array"], + "properties": ["range query in O(1)", "needs an inverse", "keeps every prefix"] + }, + { + "name": "prefix sum + hash map", + "aliases": ["map of seen prefix sums", "prefix count map"], + "properties": ["needs an inverse", "single pass", "O(1) lookup", "handles negatives"] + }, + { + "name": "Boyer-Moore majority vote", + "aliases": ["Boyer–Moore", "majority vote", "candidate and counter"], + "properties": ["needs a majority", "single pass", "O(1) extra space"] + }, + { + "name": "monotonic stack", + "aliases": ["monotone stack", "increasing stack", "decreasing stack"], + "properties": ["last-in-first-out", "next-greater lookup", "amortized O(n)"] + }, + { + "name": "sort by start + merge", + "aliases": ["merge intervals", "sort by start"], + "properties": ["needs sorted input", "overlaps become adjacent", "single pass"] + }, + { + "name": "sort by end + greedy pick", + "aliases": ["sort by end", "interval scheduling", "activity selection"], + "properties": ["needs sorted input", "earliest end first", "greedy"] + }, + { + "name": "heap", + "aliases": ["priority queue", "min-heap", "max-heap", "heapq"], + "properties": ["O(log n) push and pop-min", "partial order"] + } + ] +} diff --git a/leetcode_coach/leetcode_coach/toolbox.py b/leetcode_coach/leetcode_coach/toolbox.py new file mode 100644 index 0000000..c6a088b --- /dev/null +++ b/leetcode_coach/leetcode_coach/toolbox.py @@ -0,0 +1,22 @@ +"""The operation toolbox (TODO-coach.md decision 9), read from ``toolbox.json``. + +Schema: ``{"entries": [{"name", "aliases": [...], "properties": [...]}], +"properties": {"": ""}}``. Every entry property is a +key of ``properties``: the vocabulary is fixed, so the requirement checker and +the post-mortem cards speak the same words. kuhy reviews the JSON file itself. +""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any + +TOOLBOX_PATH = Path(__file__).with_name("toolbox.json") + + +def load(path: Path | None = None) -> dict[str, Any]: + """The parsed toolbox; ``path`` defaults to the shipped file at call time.""" + source = TOOLBOX_PATH if path is None else path + data: dict[str, Any] = json.loads(source.read_text(encoding="utf-8")) + return data diff --git a/leetcode_coach/leetcode_coach/toolbox_cards.py b/leetcode_coach/leetcode_coach/toolbox_cards.py new file mode 100644 index 0000000..23d54c3 --- /dev/null +++ b/leetcode_coach/leetcode_coach/toolbox_cards.py @@ -0,0 +1,153 @@ +"""Toolbox entries as post-mortem cards, and the one way to grow the toolbox. + +TODO-coach.md decision 11: post-mortem cards are property -> operation only. +The front lists an entry's properties, the back is its name, so the card asks +"which tool has exactly these properties?". All cards share the fixed slug +``toolbox``: the card builder derives a note's guid from (slug, front), so one +slug means one note per entry however many problems used it. + + python3 -m leetcode_coach.toolbox_cards cards ... (stdout -> + the JSON make_cards.py reads; pipe it straight in) + python3 -m leetcode_coach.toolbox_cards add [--alias A]... --property P... + +``add`` (``append_entry``) is the only writer of toolbox.json and the coach +calls it only after kuhy has confirmed the properties. It refuses unknown +properties, a name or alias already taken, and a property set that nests with +an existing entry's (that card would have two right answers). Its printed +lines are its interface (T201 exemption in pyproject.toml). +""" + +from __future__ import annotations + +import argparse +import json +import logging +import os +from pathlib import Path +import sys +import tempfile +from typing import Any + +from leetcode_coach import card_rules, toolbox + +_logger = logging.getLogger(__name__) + +SLUG = "toolbox" +FRONT_PREFIX = "Toolbox: " + + +def _key(text: str) -> str: + """Case- and space-insensitive form of a name or alias.""" + return " ".join(text.split()).casefold() + + +def find(data: dict[str, Any], name: str) -> dict[str, Any]: + """The entry called ``name`` (or with that alias); KeyError if there is none.""" + wanted = _key(name) + for entry in data["entries"]: + if wanted in {_key(n) for n in [entry["name"], *entry["aliases"]]}: + return dict(entry) + msg = f"{name!r} is not in the toolbox" + raise KeyError(msg) + + +def entry_card(entry: dict[str, Any]) -> card_rules.Card: + """The property -> operation card for one entry.""" + front = FRONT_PREFIX + ", ".join(entry["properties"]) + "?" + return {"front": front, "back": str(entry["name"])} + + +def cards_for(names: list[str], path: Path | None = None) -> list[card_rules.Card]: + """Cards for the named entries, in the order given.""" + data = toolbox.load(path) + return [entry_card(find(data, name)) for name in names] + + +def _problems( + data: dict[str, Any], name: str, aliases: list[str], properties: list[str] +) -> list[str]: + """Every reason the candidate entry may not be added (empty = ok).""" + if not name.strip(): + return ["empty name"] + if not properties: + return ["no properties"] + problems: list[str] = [] + if unknown := sorted(set(properties) - set(data["properties"])): + problems.append("unknown properties: " + ", ".join(unknown)) + if len(set(properties)) != len(properties): + problems.append("a property is listed twice") + taken = {_key(n) for e in data["entries"] for n in [e["name"], *e["aliases"]]} + if clash := [n for n in [name, *aliases] if _key(n) in taken]: + problems.append(f"already in the toolbox: {', '.join(clash)}") + if problems: + return problems + mine = set(properties) + for entry in data["entries"]: + theirs = set(entry["properties"]) + if mine <= theirs or theirs <= mine: + problems.append(f"property set nests with {entry['name']!r}") + candidate = {"name": name, "aliases": aliases, "properties": properties} + problems += card_rules.validate(SLUG, [entry_card(candidate)]) + return problems + + +def append_entry( + name: str, + aliases: list[str], + properties: list[str], + *, + path: Path | None = None, +) -> dict[str, Any]: + """Append one confirmed entry to toolbox.json; ValueError if it is refused.""" + target = toolbox.TOOLBOX_PATH if path is None else path + data = toolbox.load(target) + if problems := _problems(data, name, aliases, properties): + raise ValueError("; ".join(problems)) + entry = {"name": name.strip(), "aliases": aliases, "properties": properties} + data["entries"].append(entry) + text = json.dumps(data, indent=2, ensure_ascii=False) + "\n" + # Written beside the target and renamed over it: a crash mid-write must + # not leave kuhy's reviewed toolbox half a file. + fd, tmp = tempfile.mkstemp(dir=target.parent, prefix=".toolbox-", suffix=".json") + with os.fdopen(fd, "w", encoding="utf-8") as fh: + fh.write(text) + Path(tmp).replace(target) + return entry + + +def _parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(prog="toolbox_cards") + sub = parser.add_subparsers(dest="command", required=True) + cards = sub.add_parser("cards", help="print make_cards.py input for entries") + cards.add_argument("names", nargs="+") + add = sub.add_parser("add", help="append an entry kuhy has confirmed") + add.add_argument("name") + add.add_argument("--alias", action="append", default=[]) + add.add_argument("--property", action="append", default=[], required=True) + return parser + + +def main(argv: list[str]) -> int: + """`cards` prints builder input; `add` appends a confirmed entry.""" + args = _parser().parse_args(argv) + if args.command == "cards": + try: + cards = cards_for(args.names) + except KeyError as exc: + _logger.warning("cards for an unknown entry: %s", exc) + print(exc.args[0], file=sys.stderr) + return 1 + print(json.dumps({"slug": SLUG, "cards": cards}, ensure_ascii=False)) + return 0 + try: + entry = append_entry(args.name, args.alias, args.property) + except ValueError as exc: + _logger.warning("toolbox entry refused: %s", exc) + print(f"not added: {exc}", file=sys.stderr) + return 1 + print(f"added {entry['name']!r}: {', '.join(entry['properties'])}") + return 0 + + +if __name__ == "__main__": + sys.exit(main(sys.argv[1:]))