mirror of
https://github.com/kuhyx/utils.git
synced 2026-11-11 13:27:44 +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
|
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
|
unless kuhy types `reveal`. Use on /leetcode <url> [what you are stuck
|
||||||
on] AND whenever kuhy pastes a leetcode.com/problems/ URL without SOLVE:
|
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)
|
(hook leetcode_url_userprompt.sh tells you to). Ends with a post-mortem: property -> operation
|
||||||
about the confusions, never the solution.
|
Anki cards generated from the toolbox, never the solution.
|
||||||
argument-hint: "<leetcode URL or slug> [what you're stuck on]"
|
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;
|
is wrong with it now. A passing submission does not retire a misconception;
|
||||||
only a corrected sentence from them does.
|
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
|
1. Ask kuhy for the requirement list as *properties* (what must vanish, what
|
||||||
card; the original confusion is always one. Drop the ones that were
|
must survive, how much space), not code or an operation name; then, for
|
||||||
about *this problem* rather than a reusable mechanism. Test: would the
|
every hint rung kuhy saw, which requirement that hint handed over.
|
||||||
card still be worth reviewing if kuhy never saw this problem again?
|
2. Cards are **property -> operation only** (TODO-coach.md decision 11): one
|
||||||
A card restating the rule kuhy reasoned out in chat fails it.
|
per toolbox entry the solution relied on, generated from `toolbox.json`
|
||||||
- Knowing *what* to do but failing to write it (every failed code
|
(front = its properties, back = its name), in ONE Bash call:
|
||||||
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):
|
|
||||||
```bash
|
```bash
|
||||||
~/.claude/skills/leetcode/make_cards.py - <<'EOF'
|
python3 -m leetcode_coach.toolbox_cards cards "<entry>" ["<entry>" ...] \
|
||||||
{"slug": "<slug>", "cards": [{"front": "...", "back": "..."}]}
|
| ~/.claude/skills/leetcode/make_cards.py -
|
||||||
EOF
|
|
||||||
```
|
```
|
||||||
- front: one question, ≤20 words, prefixed with the language
|
Names or aliases, 1-6 per call. They all share the slug `toolbox`, so
|
||||||
("JS: cancel a pending setTimeout?")
|
never `--replace` here: it would delete every other entry's card.
|
||||||
- back: ≤8 words, ideally one token (`clearTimeout(id)`). The builder
|
3. An operation kuhy named that is **not** in the toolbox: propose its
|
||||||
rejects longer backs — shorten, do not argue with it.
|
properties from the `toolbox.json` vocabulary and ask kuhy to confirm or
|
||||||
- 1–6 cards. Never a card whose back is the solution to the problem.
|
correct them. Only after kuhy confirms, append it (`append_entry`) --
|
||||||
On "cards rejected", fix the named card and rerun.
|
never on your own judgement, never with unconfirmed properties:
|
||||||
3. Relay the script's stdout verbatim as markdown, NOT in a code fence
|
```bash
|
||||||
(the table must render). With Anki open (AnkiConnect) the cards are in
|
python3 -m leetcode_coach.toolbox_cards add "<name>" --alias "<alias>" \
|
||||||
deck "LeetCode" and synced. Otherwise it wrote an `.apkg` and printed a
|
--property "<prop>" --property "<prop>"
|
||||||
bare `anki '<path>'` line to run (kuhy pasted the old `import: anki …`
|
```
|
||||||
label as a command, twice); do not launch anki yourself. Re-running
|
Refused because the set nests with another entry's? Ask kuhy which
|
||||||
updates the same notes. To *rewrite* a deck, `--replace` (Anki open)
|
property tells the two apart. Then build its card as in step 2.
|
||||||
deletes dropped cards; keep a good card's front byte-identical so its
|
4. Relay the builder's stdout verbatim -- as markdown, NOT inside a code
|
||||||
history survives. `--dry-run` validates without delivering.
|
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?"
|
## 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
|
That is a request for a method, coached like any rung. No reassurance ("you
|
||||||
on kuhy's ability, no "everyone memorises it" (2026-10-20: three replies of
|
did most of it"), no verdict on kuhy's ability, no "everyone memorises it".
|
||||||
comfort first). Say bluntly who supplied which step, never reframe kuhy's
|
Say bluntly who supplied which step, never reframe kuhy's own diagnosis of
|
||||||
diagnosis as the problem's fault, then have kuhy compare the `req:` list
|
a mistake as the problem's fault, then have kuhy write the requirement list
|
||||||
with what the solution needed and name the requirement a hint gave them.
|
(what vanishes, what survives, what that demands of the operation) and name
|
||||||
That method gets a card (rebuild the deck: `--replace` or rerun).
|
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
|
## 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`
|
then continue with the opening. `hooks/leetcode_model_gate_userprompt.sh`
|
||||||
enforces it from the next prompt on.
|
enforces it from the next prompt on.
|
||||||
|
|
||||||
The right client is `leetcode <url>` (this package's `leetcode` launcher,
|
The right client is `leetcode <url>` (print mode: the Stop hook can still
|
||||||
symlinked from `~/.local/bin`): a print-mode loop on sonnet that also runs
|
withhold a reply; the TUI has already streamed it). `leetcode --local` runs
|
||||||
the req-before-code gate and the session log (`leetcode report`). The Stop
|
offline on Ollama; everything above `## Model and client` is its system
|
||||||
hook (`stop_gate.py`) sends back a reply with a pasteable line or an early
|
prompt. Clients, gates and the session log: `DOCS-clients.md` here.
|
||||||
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.
|
|
||||||
|
|||||||
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
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import functools
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import sys
|
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
|
from leetcode_coach.claude_cli import ask, is_limit_hit
|
||||||
|
|
||||||
# Named, not __name__: under `-m` this module is `__main__`, which would escape
|
# 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:
|
def main(argv: list[str]) -> int:
|
||||||
"""Parse `[--local] <url-or-slug> [stuck on...]` (or `report`) and run it."""
|
"""Parse `[--local] <url-or-slug> [stuck on...]` (or `report`) and run it."""
|
||||||
local = "--local" in argv
|
local = "--local" in argv
|
||||||
args = [a for a in argv if a != "--local"]
|
args = [a for a in argv if a != "--local"]
|
||||||
if args[:1] == ["report"]:
|
if args[:1] == [report.COMMAND]:
|
||||||
return report.main(args[1:])
|
return report.main(args[1:])
|
||||||
if not args or args[0] in {"-h", "--help"}:
|
if not args or args[0] in {"-h", "--help"}:
|
||||||
print("Usage: leetcode [--local] <url-or-slug> [what you are stuck on...]")
|
print("Usage: leetcode [--local] <url-or-slug> [what you are stuck on...]")
|
||||||
print(" LEETCODE_MODEL=opus leetcode ... to use a stronger model")
|
print(" LEETCODE_MODEL=opus leetcode ... to use a stronger model")
|
||||||
print(" --local coach on the local Ollama model (free, offline)")
|
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")
|
print(" leetcode report req-before-code, max rung, reveal per problem")
|
||||||
return 0
|
return 0
|
||||||
if local:
|
if args[0] in cluster_cli.COMMANDS:
|
||||||
opener = local_session.opening_message(args[1:])
|
return cluster_cli.main(args, functools.partial(start_session, local=local))
|
||||||
gate = phase.PhaseGate.open(args[0])
|
return start_session(args, local=local)
|
||||||
return local_session.run(args[0], [], opener, read_block, gate=gate)
|
|
||||||
return run_claude(os.environ.get("LEETCODE_MODEL", "sonnet"), args)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
@ -14,6 +14,8 @@ import subprocess
|
|||||||
import sys
|
import sys
|
||||||
from typing import TYPE_CHECKING, Any
|
from typing import TYPE_CHECKING, Any
|
||||||
|
|
||||||
|
from leetcode_coach import toolbox_cards
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from collections.abc import Callable
|
from collections.abc import Callable
|
||||||
|
|
||||||
@ -43,6 +45,15 @@ def deliver_cards(
|
|||||||
"""Pipe the cards to the card builder; (accepted, its stdout+stderr)."""
|
"""Pipe the cards to the card builder; (accepted, its stdout+stderr)."""
|
||||||
if "invalid" in payload:
|
if "invalid" in payload:
|
||||||
return False, f"cards block is not a JSON object: {payload['invalid']!r}"
|
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(
|
proc = run(
|
||||||
[sys.executable, "-m", "leetcode_coach.make_cards", "-"],
|
[sys.executable, "-m", "leetcode_coach.make_cards", "-"],
|
||||||
input=json.dumps(payload),
|
input=json.dumps(payload),
|
||||||
|
|||||||
@ -45,11 +45,12 @@ LOCAL_NOTES = """
|
|||||||
that typing `reveal` ends the exercise.
|
that typing `reveal` ends the exercise.
|
||||||
- Never repeat a previous reply. Answer what kuhy just wrote.
|
- 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
|
- 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
|
```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)
|
# The problem (already fetched)
|
||||||
|
|
||||||
|
|||||||
@ -41,7 +41,7 @@ from typing import Protocol, cast
|
|||||||
|
|
||||||
import genanki
|
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
|
DECK_ID = 1_695_042_117_001 # fixed: every session's file imports into the same deck
|
||||||
MODEL_ID = 1_695_042_117_002
|
MODEL_ID = 1_695_042_117_002
|
||||||
@ -159,10 +159,13 @@ def main() -> int:
|
|||||||
if online:
|
if online:
|
||||||
done = push_direct(slug, cards, replace=replace)
|
done = push_direct(slug, cards, replace=replace)
|
||||||
print(f"{len(cards)} cards -> Anki desktop: {done}")
|
print(f"{len(cards)} cards -> Anki desktop: {done}")
|
||||||
return 0
|
else:
|
||||||
out = build_apkg(slug, cards, today)
|
out = build_apkg(slug, cards, today)
|
||||||
print(f"{len(cards)} cards -> {out} (Anki desktop not open). Import with:")
|
print(f"{len(cards)} cards -> {out} (Anki desktop not open). Import with:")
|
||||||
print(f"anki {shlex.quote(str(out))}")
|
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
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -115,7 +115,7 @@ class PhaseGate:
|
|||||||
def source() -> str:
|
def source() -> str:
|
||||||
return local_session.fetched_statement(problem) or session_log.slug(problem)
|
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:
|
def statement(self) -> str:
|
||||||
"""The problem statement, fetched once on first use."""
|
"""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:
|
if TYPE_CHECKING:
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
COMMAND = "report"
|
||||||
RUNGS = ("H0", "H1", "H2", "H3")
|
RUNGS = ("H0", "H1", "H2", "H3")
|
||||||
WIDTHS = (36, 17, 10, 8)
|
WIDTHS = (36, 17, 10, 8)
|
||||||
HEADER = ("problem", "req-before-code", "max-rung", "reveal", "req_check")
|
HEADER = ("problem", "req-before-code", "max-rung", "reveal", "req_check")
|
||||||
|
|||||||
@ -2,12 +2,15 @@
|
|||||||
|
|
||||||
One JSONL file per problem and day,
|
One JSONL file per problem and day,
|
||||||
``~/.local/share/leetcode-coach/sessions/<YYYY-MM-DD>-<slug>.jsonl`` (the
|
``~/.local/share/leetcode-coach/sessions/<YYYY-MM-DD>-<slug>.jsonl`` (the
|
||||||
directory follows ``LEETCODE_COACH_SESSIONS`` when set, for tests). Every line
|
directory follows ``LEETCODE_COACH_SESSIONS_DIR``: `progress.sessions_dir`).
|
||||||
is one object with ``event`` and ``ts``; ``req_check`` carries ``verdict``,
|
Every line is one object with ``event`` and ``ts``; ``req_check`` carries ``verdict``,
|
||||||
``rung`` carries ``rung``. That contract is FIXED: ``leetcode_guard._coach_log``
|
``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
|
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.
|
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
|
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.
|
reader sees whole lines, and two writers (REPL and a hook) cannot interleave.
|
||||||
"""
|
"""
|
||||||
@ -22,22 +25,20 @@ from pathlib import Path
|
|||||||
import re
|
import re
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from leetcode_coach import fetch_problem
|
from leetcode_coach import fetch_problem, progress
|
||||||
|
|
||||||
_LOG = logging.getLogger(__name__)
|
_LOG = logging.getLogger(__name__)
|
||||||
|
|
||||||
SESSIONS_ENV = "LEETCODE_COACH_SESSIONS"
|
|
||||||
LOG_ENV = "LEETCODE_COACH_LOG"
|
LOG_ENV = "LEETCODE_COACH_LOG"
|
||||||
"""Set by the REPL so the Stop hook (a grandchild) reads the same 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(
|
EVENTS = frozenset(
|
||||||
{"req", "req_check", "code", "rung", "reveal", "postmortem", "retest"}
|
{"req", "req_check", "code", "rung", "reveal", "postmortem", "retest"}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def sessions_dir() -> Path:
|
def sessions_dir() -> Path:
|
||||||
"""Where the session files live."""
|
"""Where the session files live (one definition: `progress.sessions_dir`)."""
|
||||||
return Path(os.environ.get(SESSIONS_ENV) or DEFAULT_DIR)
|
return progress.sessions_dir()
|
||||||
|
|
||||||
|
|
||||||
def slug(problem: str) -> str:
|
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"
|
msg = f"{event!r} is not a session-log event; add a field instead"
|
||||||
raise ValueError(msg)
|
raise ValueError(msg)
|
||||||
stamp = datetime.now(tz=UTC).astimezone().isoformat(timespec="seconds")
|
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()
|
line = (json.dumps(record, ensure_ascii=False) + "\n").encode()
|
||||||
path.parent.mkdir(parents=True, exist_ok=True)
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
fd = os.open(path, os.O_WRONLY | os.O_APPEND | os.O_CREAT, 0o644)
|
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:
|
def has_code(path: Path) -> bool:
|
||||||
"""Whether kuhy's code has reached the coach in this session."""
|
"""Whether kuhy's code has reached the coach in this session."""
|
||||||
return any(e.get("event") == "code" for e in read(path))
|
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
|
return None
|
||||||
if not _code_seen(users):
|
if not _code_seen(users):
|
||||||
quoted = [gate_detector.allowed_text(entries)]
|
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:
|
if named:
|
||||||
return local_prompt.GATE_REASON_LEAK.format(names=", ".join(named))
|
return local_prompt.GATE_REASON_LEAK.format(names=", ".join(named))
|
||||||
idents = gate_detector.starter_identifiers(entries)
|
idents = gate_detector.starter_identifiers(entries)
|
||||||
|
|||||||
@ -1,10 +1,11 @@
|
|||||||
"""Keep every coach test off the real machine.
|
"""Keep every coach test off the real machine.
|
||||||
|
|
||||||
No session log under ~/.local, no real `claude` binary, no Ollama on :11434,
|
No session log under ~/.local, no real `claude` binary, no Ollama on :11434,
|
||||||
and the fixture toolbox instead of whatever `toolbox.json` holds today
|
no cluster leaking in from the shell, and the gates (`toolbox.load_safe`) on
|
||||||
(another session fills it in). Env vars are *set* (to empty or a dead path)
|
the small fixture toolbox, so a reviewed edit of the real `toolbox.json` cannot
|
||||||
rather than deleted, so teardown also removes the `LEETCODE_COACH_LOG` the
|
flip a leak test. `toolbox.load` (cards, clusters) still reads the real file.
|
||||||
REPL exports for its hooks.
|
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
|
from __future__ import annotations
|
||||||
@ -13,7 +14,7 @@ from typing import TYPE_CHECKING
|
|||||||
|
|
||||||
import pytest
|
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
|
from leetcode_coach.tests._shared import FIXTURE_TOOLBOX
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
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)
|
@pytest.fixture(autouse=True)
|
||||||
def _hermetic_coach(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
def _hermetic_coach(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
monkeypatch.setenv("LEETCODE_COACH_SESSIONS", str(tmp_path / "sessions"))
|
monkeypatch.setenv(progress.SESSIONS_ENV, str(tmp_path / "sessions"))
|
||||||
monkeypatch.setenv("LEETCODE_COACH_LOG", "")
|
monkeypatch.setenv(progress.CLUSTER_ENV, "")
|
||||||
|
monkeypatch.setenv(session_log.LOG_ENV, "")
|
||||||
monkeypatch.setenv("LEETCODE_CLAUDE_BIN", str(tmp_path / "no-such-claude"))
|
monkeypatch.setenv("LEETCODE_CLAUDE_BIN", str(tmp_path / "no-such-claude"))
|
||||||
monkeypatch.setattr(local_backend, "OLLAMA_URL", DEAD_OLLAMA)
|
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 import leak, toolbox
|
||||||
from leetcode_coach.tests._shared import FIXTURE_TOOLBOX
|
from leetcode_coach.tests._shared import FIXTURE_TOOLBOX
|
||||||
|
|
||||||
BOX = toolbox.load(FIXTURE_TOOLBOX)
|
BOX = toolbox.load_safe(FIXTURE_TOOLBOX)
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
@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
|
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(
|
def test_rejected_cards_are_fed_back_once_fixed(
|
||||||
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
||||||
) -> None:
|
) -> None:
|
||||||
|
|||||||
@ -163,7 +163,7 @@ def test_run_with_a_gate_holds_code_back(
|
|||||||
) -> None:
|
) -> None:
|
||||||
fake = FakeChat(SAFE, SAFE)
|
fake = FakeChat(SAFE, SAFE)
|
||||||
_session(monkeypatch, chat=fake)
|
_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"])
|
blocks = iter(["function f(nums) {\n return 0;\n}", "hint", "quit"])
|
||||||
assert local_session.run("x", [], "Start.", lambda: next(blocks), gate=gate) == 0
|
assert local_session.run("x", [], "Start.", lambda: next(blocks), gate=gate) == 0
|
||||||
out = capsys.readouterr().out
|
out = capsys.readouterr().out
|
||||||
|
|||||||
@ -5,11 +5,14 @@ from __future__ import annotations
|
|||||||
import io
|
import io
|
||||||
import json
|
import json
|
||||||
import runpy
|
import runpy
|
||||||
from typing import Any
|
from typing import TYPE_CHECKING, Any
|
||||||
|
|
||||||
import pytest
|
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]:
|
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__")
|
runpy.run_path(make_cards.__file__, run_name="__main__")
|
||||||
assert exit_info.value.code == 1
|
assert exit_info.value.code == 1
|
||||||
assert capsys.readouterr().err.startswith("Usage: make_cards.py")
|
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)}"
|
return f"statement {len(fetched)}"
|
||||||
|
|
||||||
log = tmp_path / "log.jsonl"
|
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]:
|
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(
|
def test_a_statement_naming_an_entry_lets_the_coach_restate_it(
|
||||||
tmp_path: Path,
|
tmp_path: Path,
|
||||||
) -> None:
|
) -> 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", []) == []
|
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
|
assert req_check.extract_json(text) == obj
|
||||||
|
|
||||||
|
|
||||||
VOCAB = toolbox.load(FIXTURE_TOOLBOX).properties
|
VOCAB = toolbox.load_safe(FIXTURE_TOOLBOX).properties
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
@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:
|
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 STATEMENT in prompt
|
||||||
assert "req: one pass" in prompt
|
assert "req: one pass" in prompt
|
||||||
assert "self-inverse: applying it twice" 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:
|
def test_haiku_answers_first(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
sent = _ollama(monkeypatch, "unused")
|
sent = _ollama(monkeypatch, "unused")
|
||||||
ask = _haiku({"verdict": "incomplete", "missing": ["constant-space", "XOR"]})
|
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")
|
assert got == req_check.Check("incomplete", ("constant-space",), "haiku")
|
||||||
args, _prompt = ask.calls[0]
|
args, _prompt = ask.calls[0]
|
||||||
assert args[:2] == ["--model", "haiku"]
|
assert args[:2] == ["--model", "haiku"]
|
||||||
@ -114,7 +114,7 @@ def test_ollama_steps_in_when_haiku_cannot_answer(
|
|||||||
monkeypatch: pytest.MonkeyPatch, haiku_says: object
|
monkeypatch: pytest.MonkeyPatch, haiku_says: object
|
||||||
) -> None:
|
) -> None:
|
||||||
_ollama(monkeypatch, '{"verdict": "pass", "missing": []}')
|
_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")
|
assert got == req_check.Check("pass", (), "ollama")
|
||||||
|
|
||||||
|
|
||||||
@ -125,7 +125,7 @@ def test_both_down_fails_open_unverified(
|
|||||||
monkeypatch: pytest.MonkeyPatch, ollama: str | Exception
|
monkeypatch: pytest.MonkeyPatch, ollama: str | Exception
|
||||||
) -> None:
|
) -> None:
|
||||||
_ollama(monkeypatch, ollama)
|
_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")
|
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:
|
def test_the_default_asker_is_the_claude_cli(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
ask = _haiku({"verdict": "pass"})
|
ask = _haiku({"verdict": "pass"})
|
||||||
monkeypatch.setattr(claude_cli, "ask", ask)
|
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"]
|
assert ask.calls[0][0][:2] == ["--model", "haiku"]
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -9,19 +9,31 @@ from typing import TYPE_CHECKING
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from leetcode_coach import session_log
|
from leetcode_coach import progress, session_log
|
||||||
from leetcode_guard import _coach_log
|
from leetcode_guard import _coach_log
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from pathlib import Path
|
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
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||||
) -> None:
|
) -> None:
|
||||||
assert session_log.sessions_dir() == tmp_path / "sessions"
|
path = tmp_path / "log.jsonl"
|
||||||
monkeypatch.setenv(session_log.SESSIONS_ENV, "")
|
session_log.append(path, "req")
|
||||||
assert session_log.sessions_dir() == session_log.DEFAULT_DIR
|
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(
|
@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
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import io
|
||||||
import json
|
import json
|
||||||
import logging
|
import shutil
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from leetcode_coach import toolbox
|
from leetcode_coach import card_rules, progress, toolbox, toolbox_cards
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
def test_load_reads_the_configured_toolbox() -> None:
|
@pytest.fixture
|
||||||
box = toolbox.load()
|
def box(tmp_path: Path) -> Path:
|
||||||
xor = box.entries[0]
|
"""A private copy of the shipped toolbox, safe to append to."""
|
||||||
assert xor.name == "XOR"
|
copy = tmp_path / "toolbox.json"
|
||||||
assert xor.names() == ("XOR", "^", "exclusive or", "bitwise xor")
|
shutil.copy(toolbox.TOOLBOX_PATH, copy)
|
||||||
assert xor.properties == ("self-inverse", "commutative", "associative")
|
return copy
|
||||||
assert box.properties["constant-space"] == "memory does not grow with the input"
|
|
||||||
|
|
||||||
|
|
||||||
def test_aliases_and_properties_are_optional(tmp_path: Path) -> None:
|
def test_every_property_is_in_the_vocabulary_and_explained() -> None:
|
||||||
path = tmp_path / "box.json"
|
data = toolbox.load()
|
||||||
path.write_text(json.dumps({"entries": [{"name": "stack"}]}), encoding="utf-8")
|
vocab = data["properties"]
|
||||||
box = toolbox.load(path)
|
assert all(isinstance(m, str) and m.strip() for m in vocab.values())
|
||||||
assert box.entries[0].names() == ("stack",)
|
for entry in data["entries"]:
|
||||||
assert box.entries[0].properties == ()
|
assert entry["properties"], entry["name"]
|
||||||
assert box.properties == {}
|
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(
|
@pytest.mark.parametrize(
|
||||||
"body",
|
"name",
|
||||||
[
|
[
|
||||||
"not json",
|
"XOR",
|
||||||
json.dumps([]),
|
"per-bit counts mod k",
|
||||||
json.dumps({"entries": "XOR"}),
|
"Boyer-Moore majority vote",
|
||||||
json.dumps({"entries": [{"aliases": []}]}),
|
"two pointers",
|
||||||
json.dumps({"entries": [{"name": "x", "aliases": [1]}]}),
|
"sliding window",
|
||||||
json.dumps({"entries": [], "properties": {"p": 3}}),
|
"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(
|
def test_the_seeded_clusters_are_covered(name: str) -> None:
|
||||||
tmp_path: Path, body: str, caplog: pytest.LogCaptureFixture
|
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:
|
) -> None:
|
||||||
path = tmp_path / "box.json"
|
before = box.read_text(encoding="utf-8")
|
||||||
path.write_text(body, encoding="utf-8")
|
with pytest.raises(ValueError, match=error):
|
||||||
with caplog.at_level(logging.WARNING, logger="leetcode_coach.toolbox"):
|
toolbox_cards.append_entry(name, aliases, properties, path=box)
|
||||||
assert toolbox.load(path) == toolbox.Toolbox()
|
assert box.read_text(encoding="utf-8") == before
|
||||||
assert "unusable" in caplog.text
|
|
||||||
|
|
||||||
|
|
||||||
def test_a_missing_toolbox_is_empty(tmp_path: Path) -> None:
|
def test_cli_cards_prints_builder_input(capsys: pytest.CaptureFixture[str]) -> None:
|
||||||
assert toolbox.load(tmp_path / "nope.json").entries == ()
|
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":
|
Schema: ``{"entries": [{"name", "aliases": [...], "properties": [...]}],
|
||||||
{"<prop>": "<one-line meaning>"}}``. The entries feed the leak bounce (no
|
"properties": {"<prop>": "<one-line meaning>"}}``. Every entry property is a
|
||||||
entry may be named before kuhy's first code) and the property vocabulary is
|
key of ``properties``: the vocabulary is fixed, so the requirement checker and
|
||||||
the only thing the requirement checker may say back. The data is
|
the post-mortem cards speak the same words. kuhy reviews the JSON file itself.
|
||||||
TODO-coach.md item 9's; this module only reads it.
|
|
||||||
|
|
||||||
An unreadable or malformed file is an empty toolbox plus a warning: both
|
`load` is the raw parse (cards, clusters). `load_safe` is the typed view the
|
||||||
consumers then fail open (nothing bounced, req check ``unverified``) rather
|
gates use -- the leak bounce (no entry named before kuhy's first code) and the
|
||||||
than locking kuhy out of a session over a typo in a JSON file.
|
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
|
from __future__ import annotations
|
||||||
@ -17,12 +19,20 @@ from dataclasses import dataclass, field
|
|||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
_LOG = logging.getLogger(__name__)
|
_LOG = logging.getLogger(__name__)
|
||||||
|
|
||||||
TOOLBOX_PATH = Path(__file__).with_name("toolbox.json")
|
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)
|
@dataclass(frozen=True)
|
||||||
class Entry:
|
class Entry:
|
||||||
"""One operation: how it is called and which properties it has."""
|
"""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):
|
if not isinstance(raw, dict) or not isinstance(raw.get("entries"), list):
|
||||||
msg = "the toolbox must be an object with an entries list"
|
msg = "the toolbox must be an object with an entries list"
|
||||||
raise TypeError(msg)
|
raise TypeError(msg)
|
||||||
@ -75,11 +85,11 @@ def _parse(raw: object) -> Toolbox:
|
|||||||
return Toolbox(tuple(_entry(e) for e in raw["entries"]), dict(props))
|
return Toolbox(tuple(_entry(e) for e in raw["entries"]), dict(props))
|
||||||
|
|
||||||
|
|
||||||
def load(path: Path | None = None) -> Toolbox:
|
def load_safe(path: Path | None = None) -> Toolbox:
|
||||||
"""The toolbox at `path` (default ``toolbox.json``), or an empty one."""
|
"""The typed toolbox at `path` (default ``toolbox.json``), or an empty one."""
|
||||||
target = TOOLBOX_PATH if path is None else path
|
target = TOOLBOX_PATH if path is None else path
|
||||||
try:
|
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:
|
except (OSError, ValueError, TypeError) as exc:
|
||||||
_LOG.warning("toolbox %s is unusable, checks fail open: %s", target, exc)
|
_LOG.warning("toolbox %s is unusable, checks fail open: %s", target, exc)
|
||||||
return Toolbox()
|
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