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 <name> / --retest <slug> (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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PjvF5DZG9JduSBYKxqyysi
This commit is contained in:
Krzysztof Rudnicki 2026-10-23 15:39:52 +02:00
parent 96a0d8af07
commit 28c243d066
14 changed files with 1430 additions and 52 deletions

View File

@ -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 <url> [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: "<leetcode URL or slug> [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": "<slug>", "cards": [{"front": "...", "back": "..."}]}
EOF
python3 -m leetcode_coach.toolbox_cards cards "<entry>" ["<entry>" ...] \
| ~/.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 "<name>" --alias "<alias>" \
--property "<prop>" --property "<prop>"
```
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 '<path>'` 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 <slug>`, 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

View File

@ -0,0 +1,149 @@
"""`leetcode due`, `leetcode --cluster <name>` and `leetcode --retest <slug>`.
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 <name>`` 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 <slug>`` 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 <name> [--dry-run] | --retest <slug> [--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 <name>` and `--retest <slug>`."""
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

View File

@ -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"}
]
}
}

View File

@ -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 `<url-or-slug> [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] <url-or-slug> [stuck on...]` and run a session."""
local = "--local" in argv
@ -135,11 +144,11 @@ def main(argv: list[str]) -> int:
print("Usage: leetcode [--local] <url-or-slug> [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 <name> | --retest <slug> (--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__":

View File

@ -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),

View File

@ -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": "<slug>", "cards": [{"front": "JS: ...?", "back": "..."}]}
{"toolbox": ["<entry name>", "<entry name>"]}
```
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)

View File

@ -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 (``<YYYY-MM-DD>-<slug>.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<day>\d{4}-\d{2}-\d{2})-(?P<slug>.+)")
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]]]:
"""``{"<cluster>": [{"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

View File

@ -0,0 +1,248 @@
"""Tests for `leetcode due`, `leetcode --cluster <name>` and `leetcode --retest <slug>`.
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"]

View File

@ -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:

View File

@ -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

View File

@ -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

View File

@ -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"]
}
]
}

View File

@ -0,0 +1,22 @@
"""The operation toolbox (TODO-coach.md decision 9), read from ``toolbox.json``.
Schema: ``{"entries": [{"name", "aliases": [...], "properties": [...]}],
"properties": {"<prop>": "<one-line meaning>"}}``. 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

View File

@ -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 <name|alias>... (stdout ->
the JSON make_cards.py reads; pipe it straight in)
python3 -m leetcode_coach.toolbox_cards add <name> [--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:]))