Merge main (toolbox, clusters, re-tests) into the coach gate branch

The gates read the toolbox through toolbox.load_safe (typed, fail-open)
beside main's raw load(); the session log follows LEETCODE_COACH_SESSIONS_DIR,
stamps LEETCODE_COACH_CLUSTER, and the card builder logs postmortem on
delivery. leetcode report dispatches beside cluster_cli; client notes move
to DOCS-clients.md to keep SKILL.md under the cap.

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:49:41 +02:00
commit ebf7ca624e
28 changed files with 1637 additions and 155 deletions

View File

@ -0,0 +1,45 @@
# The coach's clients, gates and session log
Moved out of `SKILL.md` (250-line cap); none of it is coaching instruction.
The right client is `leetcode <url>` (this package's `leetcode` launcher,
symlinked from `~/.local/bin`): a print-mode loop on sonnet that also runs
the req-before-code gate and the session log (`leetcode report`). The Stop
hook (`stop_gate.py`) sends back a reply with a pasteable line or an early
toolbox name, but the TUI has already streamed it (2026-09-21: the blocked
`for` loop was visible above the rewrite); print mode waits. TUI: best-effort.
**Offline / out of quota.** `leetcode --local <url>` coaches on
`qwen3:30b-a3b-instruct-2507` via Ollama, and the REPL switches there by
itself, history intact, on "You've hit your ... limit". With no hooks there,
`local_coach.py` runs the same detectors on every draft; three leaky drafts
are withheld. Everything above `## Model and client` is its system prompt,
minus section 1. `python3 -m leetcode_coach.eval_local`: acceptance run.
## The req-before-code gate (TODO-coach.md decisions 4-8)
Both REPL paths run `phase.PhaseGate` on every block kuhy sends:
- `req: ...` is the requirement list. `req_check` judges it on
`claude -p --model haiku` (no tools, hooks or session file), then on Ollama,
and fails OPEN (`unverified`) when neither answers. It may only name
missing properties from the `toolbox.json` vocabulary; anything else it says
is dropped before kuhy sees it.
- A block that is code (regex first, the model only when the regex cannot
tell, "not code" when nobody answers) is held back until a list passed, or
was unverifiable, or came back incomplete three times (then the `code`
event carries `req_incomplete: true`).
- Until the first `code` event, a reply naming a toolbox entry or alias is
bounced (`leak.py`: the Stop hook on the Claude path, `local_coach` on the
local one), except names kuhy or the fetched statement already used.
- Each reply's `<!-- rung:Hn -->` marker is logged and stripped.
Every step lands in `~/.local/share/leetcode-coach/sessions/<date>-<slug>.jsonl`
(`session_log.py`; `leetcode_guard._coach_log` reads the same contract). The
REPL exports the file as `LEETCODE_COACH_LOG`, which is how the Stop hook and
the card builder (`postmortem`) find it. `leetcode report` prints, per file,
req-before-code, highest rung, reveal and every req_check verdict.
Known gap: `~/.claude/hooks/status_tag_stop.py` wants the last line of a TUI
coaching reply to end in `?`; the rung line now comes after it. Print mode
(the REPL) is exempt from that hook.

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]"
---
@ -163,57 +163,62 @@ 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, also after shortening it to 8 words (the audit's two false backs
were born that way: "`n /= 10` loop never terminates" — it ends after
326 passes; "subtract 1 flips the rest to 1" — only the lowest bits).
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 in a code fence
(the table must render). With Anki open (AnkiConnect) the cards are in
deck "LeetCode" and synced. 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
updates the same notes. To *rewrite* a deck, `--replace` (Anki open)
deletes dropped cards; keep a good card's front byte-identical so its
history survives. `--dry-run` validates without delivering.
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.
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 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?"
A request for a method, coached like any rung. No reassurance, no verdict
on kuhy's ability, no "everyone memorises it" (2026-10-20: three replies of
comfort first). Say bluntly who supplied which step, never reframe kuhy's
diagnosis as the problem's fault, then have kuhy compare the `req:` list
with what the solution needed and name the requirement a hint gave them.
That method gets a card (rebuild the deck: `--replace` or rerun).
That is a request for a method, coached like any rung. No reassurance ("you
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. 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
@ -235,16 +240,7 @@ so if you are not sonnet, the **first line** of your opening message is
then continue with the opening. `hooks/leetcode_model_gate_userprompt.sh`
enforces it from the next prompt on.
The right client is `leetcode <url>` (this package's `leetcode` launcher,
symlinked from `~/.local/bin`): a print-mode loop on sonnet that also runs
the req-before-code gate and the session log (`leetcode report`). The Stop
hook (`stop_gate.py`) sends back a reply with a pasteable line or an early
toolbox name, but the TUI has already streamed it (2026-09-21: the blocked
`for` loop was visible above the rewrite); print mode waits. TUI: best-effort.
**Offline / out of quota.** `leetcode --local <url>` coaches on
`qwen3:30b-a3b-instruct-2507` via Ollama, and the REPL switches there by
itself, history intact, on "You've hit your ... limit". With no hooks there,
`local_coach.py` runs the same detectors on every draft; three leaky drafts
are withheld. Everything above `## Model and client` is its system prompt,
minus section 1. `python3 -m leetcode_coach.eval_local`: acceptance run.
The right client is `leetcode <url>` (print mode: the Stop hook can still
withhold a reply; the TUI has already streamed it). `leetcode --local` runs
offline on Ollama; everything above `## Model and client` is its system
prompt. Clients, gates and the session log: `DOCS-clients.md` here.

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

@ -24,11 +24,12 @@ its prints ARE the session (T201 exemption in pyproject.toml).
from __future__ import annotations
import functools
import logging
import os
import sys
from leetcode_coach import local_session, phase, report
from leetcode_coach import cluster_cli, local_session, phase, report
from leetcode_coach.claude_cli import ask, is_limit_hit
# Named, not __name__: under `-m` this module is `__main__`, which would escape
@ -97,23 +98,31 @@ 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:])
gate = phase.PhaseGate.open(problem[0])
return local_session.run(problem[0], [], opener, read_block, gate=gate)
return run_claude(os.environ.get("LEETCODE_MODEL", "sonnet"), problem)
def main(argv: list[str]) -> int:
"""Parse `[--local] <url-or-slug> [stuck on...]` (or `report`) and run it."""
local = "--local" in argv
args = [a for a in argv if a != "--local"]
if args[:1] == ["report"]:
if args[:1] == [report.COMMAND]:
return report.main(args[1:])
if not args or args[0] in {"-h", "--help"}:
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)")
print(" leetcode report req-before-code, max rung, reveal per problem")
return 0
if local:
opener = local_session.opening_message(args[1:])
gate = phase.PhaseGate.open(args[0])
return local_session.run(args[0], [], opener, read_block, gate=gate)
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

@ -41,7 +41,7 @@ from typing import Protocol, cast
import genanki
from leetcode_coach import anki_connect, card_rules
from leetcode_coach import anki_connect, card_rules, session_log
DECK_ID = 1_695_042_117_001 # fixed: every session's file imports into the same deck
MODEL_ID = 1_695_042_117_002
@ -159,10 +159,13 @@ def main() -> int:
if online:
done = push_direct(slug, cards, replace=replace)
print(f"{len(cards)} cards -> Anki desktop: {done}")
return 0
out = build_apkg(slug, cards, today)
print(f"{len(cards)} cards -> {out} (Anki desktop not open). Import with:")
print(f"anki {shlex.quote(str(out))}")
else:
out = build_apkg(slug, cards, today)
print(f"{len(cards)} cards -> {out} (Anki desktop not open). Import with:")
print(f"anki {shlex.quote(str(out))}")
# Delivered: the post-mortem is done. Re-test completion and "solved"
# (progress.py) read this event from the REPL's session log.
session_log.append_current("postmortem", cards=len(cards))
return 0

View File

@ -115,7 +115,7 @@ class PhaseGate:
def source() -> str:
return local_session.fetched_statement(problem) or session_log.slug(problem)
return cls(log, toolbox.load(), source)
return cls(log, toolbox.load_safe(), source)
def statement(self) -> str:
"""The problem statement, fetched once on first use."""

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

@ -16,6 +16,7 @@ from leetcode_coach import session_log
if TYPE_CHECKING:
from pathlib import Path
COMMAND = "report"
RUNGS = ("H0", "H1", "H2", "H3")
WIDTHS = (36, 17, 10, 8)
HEADER = ("problem", "req-before-code", "max-rung", "reveal", "req_check")

View File

@ -2,12 +2,15 @@
One JSONL file per problem and day,
``~/.local/share/leetcode-coach/sessions/<YYYY-MM-DD>-<slug>.jsonl`` (the
directory follows ``LEETCODE_COACH_SESSIONS`` when set, for tests). Every line
is one object with ``event`` and ``ts``; ``req_check`` carries ``verdict``,
directory follows ``LEETCODE_COACH_SESSIONS_DIR``: `progress.sessions_dir`).
Every line is one object with ``event`` and ``ts``; ``req_check`` carries ``verdict``,
``rung`` carries ``rung``. That contract is FIXED: ``leetcode_guard._coach_log``
reads it for the status app, so a new kind of fact rides as an extra field on
an existing event (``code`` + ``req_incomplete``), never as a new event name.
Every event carries ``cluster`` while ``LEETCODE_COACH_CLUSTER`` is set
(`leetcode --cluster` sets it for the session it starts).
Appends are one ``os.write`` on an ``O_APPEND`` descriptor, so a concurrent
reader sees whole lines, and two writers (REPL and a hook) cannot interleave.
"""
@ -22,22 +25,20 @@ from pathlib import Path
import re
from typing import Any
from leetcode_coach import fetch_problem
from leetcode_coach import fetch_problem, progress
_LOG = logging.getLogger(__name__)
SESSIONS_ENV = "LEETCODE_COACH_SESSIONS"
LOG_ENV = "LEETCODE_COACH_LOG"
"""Set by the REPL so the Stop hook (a grandchild) reads the same log."""
DEFAULT_DIR = Path.home() / ".local" / "share" / "leetcode-coach" / "sessions"
EVENTS = frozenset(
{"req", "req_check", "code", "rung", "reveal", "postmortem", "retest"}
)
def sessions_dir() -> Path:
"""Where the session files live."""
return Path(os.environ.get(SESSIONS_ENV) or DEFAULT_DIR)
"""Where the session files live (one definition: `progress.sessions_dir`)."""
return progress.sessions_dir()
def slug(problem: str) -> str:
@ -65,7 +66,9 @@ def append(path: Path, event: str, **fields: object) -> None:
msg = f"{event!r} is not a session-log event; add a field instead"
raise ValueError(msg)
stamp = datetime.now(tz=UTC).astimezone().isoformat(timespec="seconds")
record = {"event": event, "ts": stamp, **fields}
cluster = os.environ.get(progress.CLUSTER_ENV)
stamped = {"cluster": cluster} if cluster else {}
record = {"event": event, "ts": stamp, **stamped, **fields}
line = (json.dumps(record, ensure_ascii=False) + "\n").encode()
path.parent.mkdir(parents=True, exist_ok=True)
fd = os.open(path, os.O_WRONLY | os.O_APPEND | os.O_CREAT, 0o644)
@ -95,3 +98,17 @@ def read(path: Path) -> list[dict[str, Any]]:
def has_code(path: Path) -> bool:
"""Whether kuhy's code has reached the coach in this session."""
return any(e.get("event") == "code" for e in read(path))
def append_current(event: str, **fields: object) -> bool:
"""Append to the log the REPL exported (`LOG_ENV`); False outside a session.
For writers that run as the coach's grandchildren, like the card builder
logging ``postmortem`` once the cards are delivered. The TUI has no REPL
and so no log: nothing is written there.
"""
current = os.environ.get(LOG_ENV)
if not current:
return False
append(Path(current), event, **fields)
return True

View File

@ -62,7 +62,7 @@ def verdict(raw: str, reply: str) -> str | None:
return None
if not _code_seen(users):
quoted = [gate_detector.allowed_text(entries)]
named = leak.leaked_names(reply, quoted, toolbox.load())
named = leak.leaked_names(reply, quoted, toolbox.load_safe())
if named:
return local_prompt.GATE_REASON_LEAK.format(names=", ".join(named))
idents = gate_detector.starter_identifiers(entries)

View File

@ -1,10 +1,11 @@
"""Keep every coach test off the real machine.
No session log under ~/.local, no real `claude` binary, no Ollama on :11434,
and the fixture toolbox instead of whatever `toolbox.json` holds today
(another session fills it in). Env vars are *set* (to empty or a dead path)
rather than deleted, so teardown also removes the `LEETCODE_COACH_LOG` the
REPL exports for its hooks.
no cluster leaking in from the shell, and the gates (`toolbox.load_safe`) on
the small fixture toolbox, so a reviewed edit of the real `toolbox.json` cannot
flip a leak test. `toolbox.load` (cards, clusters) still reads the real file.
Env vars are *set* (to empty or a dead path) rather than deleted, so teardown
also removes the `LEETCODE_COACH_LOG` the REPL exports for its hooks.
"""
from __future__ import annotations
@ -13,7 +14,7 @@ from typing import TYPE_CHECKING
import pytest
from leetcode_coach import local_backend, toolbox
from leetcode_coach import local_backend, progress, session_log, toolbox
from leetcode_coach.tests._shared import FIXTURE_TOOLBOX
if TYPE_CHECKING:
@ -24,8 +25,14 @@ DEAD_OLLAMA = "http://127.0.0.1:9" # the discard port: connection refused at on
@pytest.fixture(autouse=True)
def _hermetic_coach(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("LEETCODE_COACH_SESSIONS", str(tmp_path / "sessions"))
monkeypatch.setenv("LEETCODE_COACH_LOG", "")
monkeypatch.setenv(progress.SESSIONS_ENV, str(tmp_path / "sessions"))
monkeypatch.setenv(progress.CLUSTER_ENV, "")
monkeypatch.setenv(session_log.LOG_ENV, "")
monkeypatch.setenv("LEETCODE_CLAUDE_BIN", str(tmp_path / "no-such-claude"))
monkeypatch.setattr(local_backend, "OLLAMA_URL", DEAD_OLLAMA)
monkeypatch.setattr(toolbox, "TOOLBOX_PATH", FIXTURE_TOOLBOX)
real = toolbox.load_safe
def fixture_toolbox(path: Path | None = None) -> toolbox.Toolbox:
return real(FIXTURE_TOOLBOX if path is None else path)
monkeypatch.setattr(toolbox, "load_safe", fixture_toolbox)

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, **_kw: 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

@ -7,7 +7,7 @@ import pytest
from leetcode_coach import leak, toolbox
from leetcode_coach.tests._shared import FIXTURE_TOOLBOX
BOX = toolbox.load(FIXTURE_TOOLBOX)
BOX = toolbox.load_safe(FIXTURE_TOOLBOX)
@pytest.mark.parametrize(

View File

@ -100,6 +100,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

@ -163,7 +163,7 @@ def test_run_with_a_gate_holds_code_back(
) -> None:
fake = FakeChat(SAFE, SAFE)
_session(monkeypatch, chat=fake)
gate = phase.PhaseGate(tmp_path / "log.jsonl", toolbox.load(), lambda: "s")
gate = phase.PhaseGate(tmp_path / "log.jsonl", toolbox.load_safe(), lambda: "s")
blocks = iter(["function f(nums) {\n return 0;\n}", "hint", "quit"])
assert local_session.run("x", [], "Start.", lambda: next(blocks), gate=gate) == 0
out = capsys.readouterr().out

View File

@ -5,11 +5,14 @@ from __future__ import annotations
import io
import json
import runpy
from typing import Any
from typing import TYPE_CHECKING, Any
import pytest
from leetcode_coach import anki_connect, make_cards
from leetcode_coach import anki_connect, make_cards, session_log
if TYPE_CHECKING:
from pathlib import Path
def _card(front: str, back: str = "x") -> dict[str, str]:
@ -124,3 +127,19 @@ def test_script_entry_point(
runpy.run_path(make_cards.__file__, run_name="__main__")
assert exit_info.value.code == 1
assert capsys.readouterr().err.startswith("Usage: make_cards.py")
def test_delivery_marks_the_post_mortem_in_the_session_log(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
monkeypatch.setattr(make_cards, "OUT_DIR", tmp_path)
monkeypatch.setattr(anki_connect, "available", lambda: False)
assert _run(monkeypatch, ["--dry-run", "-"], GOOD) == 0
assert _run(monkeypatch, ["-"], GOOD) == 0 # no session: nothing written
log = tmp_path / "log.jsonl"
monkeypatch.setenv(session_log.LOG_ENV, str(log))
assert _run(monkeypatch, ["--dry-run", "-"], GOOD) == 0
assert not log.exists()
assert _run(monkeypatch, ["-"], GOOD) == 0
(event,) = session_log.read(log)
assert (event["event"], event["cards"]) == ("postmortem", 1)

View File

@ -96,7 +96,7 @@ def _gate(
return f"statement {len(fetched)}"
log = tmp_path / "log.jsonl"
return phase.PhaseGate(log, toolbox.load(), statement), checks
return phase.PhaseGate(log, toolbox.load_safe(), statement), checks
def _events(gate: phase.PhaseGate) -> list[object]:
@ -206,7 +206,9 @@ def test_leaks_only_before_code_and_never_what_kuhy_typed(
def test_a_statement_naming_an_entry_lets_the_coach_restate_it(
tmp_path: Path,
) -> None:
gate = phase.PhaseGate(tmp_path / "l.jsonl", toolbox.load(), lambda: "Use XOR.")
gate = phase.PhaseGate(
tmp_path / "l.jsonl", toolbox.load_safe(), lambda: "Use XOR."
)
assert gate.leaks("XOR here", []) == []

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

@ -58,7 +58,7 @@ def test_extract_json(text: str, obj: object) -> None:
assert req_check.extract_json(text) == obj
VOCAB = toolbox.load(FIXTURE_TOOLBOX).properties
VOCAB = toolbox.load_safe(FIXTURE_TOOLBOX).properties
@pytest.mark.parametrize(
@ -84,7 +84,7 @@ def test_parse_verdict_keeps_only_vocabulary_properties(
def test_the_prompt_carries_statement_list_and_vocabulary() -> None:
prompt = req_check.check_prompt(STATEMENT, "req: one pass", toolbox.load())
prompt = req_check.check_prompt(STATEMENT, "req: one pass", toolbox.load_safe())
assert STATEMENT in prompt
assert "req: one pass" in prompt
assert "self-inverse: applying it twice" in prompt
@ -94,7 +94,7 @@ def test_the_prompt_carries_statement_list_and_vocabulary() -> None:
def test_haiku_answers_first(monkeypatch: pytest.MonkeyPatch) -> None:
sent = _ollama(monkeypatch, "unused")
ask = _haiku({"verdict": "incomplete", "missing": ["constant-space", "XOR"]})
got = req_check.check(STATEMENT, "req: loop", toolbox.load(), ask)
got = req_check.check(STATEMENT, "req: loop", toolbox.load_safe(), ask)
assert got == req_check.Check("incomplete", ("constant-space",), "haiku")
args, _prompt = ask.calls[0]
assert args[:2] == ["--model", "haiku"]
@ -114,7 +114,7 @@ def test_ollama_steps_in_when_haiku_cannot_answer(
monkeypatch: pytest.MonkeyPatch, haiku_says: object
) -> None:
_ollama(monkeypatch, '{"verdict": "pass", "missing": []}')
got = req_check.check(STATEMENT, "req: x", toolbox.load(), _haiku(haiku_says))
got = req_check.check(STATEMENT, "req: x", toolbox.load_safe(), _haiku(haiku_says))
assert got == req_check.Check("pass", (), "ollama")
@ -125,7 +125,7 @@ def test_both_down_fails_open_unverified(
monkeypatch: pytest.MonkeyPatch, ollama: str | Exception
) -> None:
_ollama(monkeypatch, ollama)
got = req_check.check(STATEMENT, "req: x", toolbox.load(), _haiku("nope"))
got = req_check.check(STATEMENT, "req: x", toolbox.load_safe(), _haiku("nope"))
assert got == req_check.Check("unverified", (), "none")
@ -141,7 +141,7 @@ def test_an_empty_vocabulary_cannot_be_checked(monkeypatch: pytest.MonkeyPatch)
def test_the_default_asker_is_the_claude_cli(monkeypatch: pytest.MonkeyPatch) -> None:
ask = _haiku({"verdict": "pass"})
monkeypatch.setattr(claude_cli, "ask", ask)
assert req_check.check(STATEMENT, "req: x", toolbox.load()).via == "haiku"
assert req_check.check(STATEMENT, "req: x", toolbox.load_safe()).via == "haiku"
assert ask.calls[0][0][:2] == ["--model", "haiku"]

View File

@ -9,19 +9,31 @@ from typing import TYPE_CHECKING
import pytest
from leetcode_coach import session_log
from leetcode_coach import progress, session_log
from leetcode_guard import _coach_log
if TYPE_CHECKING:
from pathlib import Path
def test_sessions_dir_follows_the_env(
def test_sessions_dir_is_the_one_progress_reads(tmp_path: Path) -> None:
assert session_log.sessions_dir() == tmp_path / "sessions"
assert session_log.sessions_dir() == progress.sessions_dir()
def test_the_cluster_env_is_stamped_on_every_event(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
assert session_log.sessions_dir() == tmp_path / "sessions"
monkeypatch.setenv(session_log.SESSIONS_ENV, "")
assert session_log.sessions_dir() == session_log.DEFAULT_DIR
path = tmp_path / "log.jsonl"
session_log.append(path, "req")
monkeypatch.setenv(progress.CLUSTER_ENV, "bit-cancellation")
session_log.append(path, "code")
session_log.append(path, "rung", rung="H1", cluster="explicit")
assert [e.get("cluster") for e in session_log.read(path)] == [
None,
"bit-cancellation",
"explicit",
]
@pytest.mark.parametrize(

View File

@ -1,57 +1,184 @@
"""Tests for the toolbox loader (the data itself belongs to TODO-coach.md item 9)."""
"""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 logging
import shutil
from typing import TYPE_CHECKING
import pytest
from leetcode_coach import toolbox
from leetcode_coach import card_rules, progress, toolbox, toolbox_cards
if TYPE_CHECKING:
from pathlib import Path
def test_load_reads_the_configured_toolbox() -> None:
box = toolbox.load()
xor = box.entries[0]
assert xor.name == "XOR"
assert xor.names() == ("XOR", "^", "exclusive or", "bitwise xor")
assert xor.properties == ("self-inverse", "commutative", "associative")
assert box.properties["constant-space"] == "memory does not grow with the input"
@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_aliases_and_properties_are_optional(tmp_path: Path) -> None:
path = tmp_path / "box.json"
path.write_text(json.dumps({"entries": [{"name": "stack"}]}), encoding="utf-8")
box = toolbox.load(path)
assert box.entries[0].names() == ("stack",)
assert box.entries[0].properties == ()
assert box.properties == {}
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_name_is_a_cluster_problem_title() -> None:
# The leak bounce fires on any entry name or alias before the first code
# event, so one that is a problem's own title bounces its restatement.
titles = {
p["slug"].replace("-", " ")
for problems in progress.load_clusters().values()
for p in problems
}
for entry in toolbox.load()["entries"]:
for name in [entry["name"], *entry["aliases"]]:
assert name.casefold() not in titles, name
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(
"body",
"name",
[
"not json",
json.dumps([]),
json.dumps({"entries": "XOR"}),
json.dumps({"entries": [{"aliases": []}]}),
json.dumps({"entries": [{"name": "x", "aliases": [1]}]}),
json.dumps({"entries": [], "properties": {"p": 3}}),
"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_a_broken_toolbox_is_empty_and_logged(
tmp_path: Path, body: str, caplog: pytest.LogCaptureFixture
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:
path = tmp_path / "box.json"
path.write_text(body, encoding="utf-8")
with caplog.at_level(logging.WARNING, logger="leetcode_coach.toolbox"):
assert toolbox.load(path) == toolbox.Toolbox()
assert "unusable" in caplog.text
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_a_missing_toolbox_is_empty(tmp_path: Path) -> None:
assert toolbox.load(tmp_path / "nope.json").entries == ()
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,57 @@
"""Tests for `toolbox.load_safe`, the fail-open typed view the gates use."""
from __future__ import annotations
import json
import logging
from typing import TYPE_CHECKING
import pytest
from leetcode_coach import toolbox
if TYPE_CHECKING:
from pathlib import Path
def test_load_reads_the_configured_toolbox() -> None:
box = toolbox.load_safe()
xor = box.entries[0]
assert xor.name == "XOR"
assert xor.names() == ("XOR", "^", "exclusive or", "bitwise xor")
assert xor.properties == ("self-inverse", "commutative", "associative")
assert box.properties["constant-space"] == "memory does not grow with the input"
def test_aliases_and_properties_are_optional(tmp_path: Path) -> None:
path = tmp_path / "box.json"
path.write_text(json.dumps({"entries": [{"name": "stack"}]}), encoding="utf-8")
box = toolbox.load_safe(path)
assert box.entries[0].names() == ("stack",)
assert box.entries[0].properties == ()
assert box.properties == {}
@pytest.mark.parametrize(
"body",
[
"not json",
json.dumps([]),
json.dumps({"entries": "XOR"}),
json.dumps({"entries": [{"aliases": []}]}),
json.dumps({"entries": [{"name": "x", "aliases": [1]}]}),
json.dumps({"entries": [], "properties": {"p": 3}}),
],
)
def test_a_broken_toolbox_is_empty_and_logged(
tmp_path: Path, body: str, caplog: pytest.LogCaptureFixture
) -> None:
path = tmp_path / "box.json"
path.write_text(body, encoding="utf-8")
with caplog.at_level(logging.WARNING, logger="leetcode_coach.toolbox"):
assert toolbox.load_safe(path) == toolbox.Toolbox()
assert "unusable" in caplog.text
def test_a_missing_toolbox_is_empty(tmp_path: Path) -> None:
assert toolbox.load_safe(tmp_path / "nope.json").entries == ()

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, +Infinity for min",
"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"],
"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": ["sort by start", "sort then combine"],
"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

@ -1,14 +1,16 @@
"""The toolbox of operations, loaded from ``toolbox.json`` next to this file.
"""The operation toolbox (TODO-coach.md decision 9), read from ``toolbox.json``.
Schema: ``{"entries": [{"name", "aliases", "properties"}], "properties":
{"<prop>": "<one-line meaning>"}}``. The entries feed the leak bounce (no
entry may be named before kuhy's first code) and the property vocabulary is
the only thing the requirement checker may say back. The data is
TODO-coach.md item 9's; this module only reads it.
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.
An unreadable or malformed file is an empty toolbox plus a warning: both
consumers then fail open (nothing bounced, req check ``unverified``) rather
than locking kuhy out of a session over a typo in a JSON file.
`load` is the raw parse (cards, clusters). `load_safe` is the typed view the
gates use -- the leak bounce (no entry named before kuhy's first code) and the
req checker (may only say vocabulary properties back). An unreadable or
malformed file is an empty `Toolbox` plus a warning there, so both gates fail
open (nothing bounced, req check ``unverified``) instead of locking kuhy out of
a session over a typo in a JSON file.
"""
from __future__ import annotations
@ -17,12 +19,20 @@ from dataclasses import dataclass, field
import json
import logging
from pathlib import Path
from typing import Any
_LOG = logging.getLogger(__name__)
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
@dataclass(frozen=True)
class Entry:
"""One operation: how it is called and which properties it has."""
@ -62,7 +72,7 @@ def _entry(raw: object) -> Entry:
)
def _parse(raw: object) -> Toolbox:
def _typed(raw: object) -> Toolbox:
if not isinstance(raw, dict) or not isinstance(raw.get("entries"), list):
msg = "the toolbox must be an object with an entries list"
raise TypeError(msg)
@ -75,11 +85,11 @@ def _parse(raw: object) -> Toolbox:
return Toolbox(tuple(_entry(e) for e in raw["entries"]), dict(props))
def load(path: Path | None = None) -> Toolbox:
"""The toolbox at `path` (default ``toolbox.json``), or an empty one."""
def load_safe(path: Path | None = None) -> Toolbox:
"""The typed toolbox at `path` (default ``toolbox.json``), or an empty one."""
target = TOOLBOX_PATH if path is None else path
try:
return _parse(json.loads(target.read_text(encoding="utf-8")))
return _typed(json.loads(target.read_text(encoding="utf-8")))
except (OSError, ValueError, TypeError) as exc:
_LOG.warning("toolbox %s is unusable, checks fail open: %s", target, exc)
return Toolbox()

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:]))