mirror of
https://github.com/kuhyx/utils.git
synced 2026-11-10 19:07:17 +01:00
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:
commit
ebf7ca624e
45
leetcode_coach/leetcode_coach/DOCS-clients.md
Normal file
45
leetcode_coach/leetcode_coach/DOCS-clients.md
Normal 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.
|
||||
@ -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.
|
||||
|
||||
149
leetcode_coach/leetcode_coach/cluster_cli.py
Normal file
149
leetcode_coach/leetcode_coach/cluster_cli.py
Normal 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
|
||||
40
leetcode_coach/leetcode_coach/clusters.json
Normal file
40
leetcode_coach/leetcode_coach/clusters.json
Normal 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"}
|
||||
]
|
||||
}
|
||||
}
|
||||
@ -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__":
|
||||
|
||||
@ -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),
|
||||
|
||||
@ -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)
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
|
||||
@ -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."""
|
||||
|
||||
217
leetcode_coach/leetcode_coach/progress.py
Normal file
217
leetcode_coach/leetcode_coach/progress.py
Normal 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
|
||||
@ -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")
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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)
|
||||
|
||||
248
leetcode_coach/leetcode_coach/tests/test_cluster_cli.py
Normal file
248
leetcode_coach/leetcode_coach/tests/test_cluster_cli.py
Normal 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"]
|
||||
@ -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(
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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", []) == []
|
||||
|
||||
|
||||
|
||||
197
leetcode_coach/leetcode_coach/tests/test_progress.py
Normal file
197
leetcode_coach/leetcode_coach/tests/test_progress.py
Normal 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
|
||||
@ -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"]
|
||||
|
||||
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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
|
||||
|
||||
57
leetcode_coach/leetcode_coach/tests/test_toolbox_safe.py
Normal file
57
leetcode_coach/leetcode_coach/tests/test_toolbox_safe.py
Normal 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 == ()
|
||||
146
leetcode_coach/leetcode_coach/toolbox.json
Normal file
146
leetcode_coach/leetcode_coach/toolbox.json
Normal 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"]
|
||||
}
|
||||
]
|
||||
}
|
||||
@ -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()
|
||||
|
||||
153
leetcode_coach/leetcode_coach/toolbox_cards.py
Normal file
153
leetcode_coach/leetcode_coach/toolbox_cards.py
Normal 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:]))
|
||||
Loading…
Reference in New Issue
Block a user