"""Self-check: work through the course's checkpoints and see where you stand. This is the tool you use *while* working through the units, not at the end. Every unit has at least one checkpoint you can pass or fail, and a wrong answer tells you which misconception you have hit rather than just saying "no". python3 selfcheck.py list # what checkpoints exist python3 selfcheck.py run cp06-b4-by-hand # attempt one python3 selfcheck.py run --unit 3 # everything for a unit python3 selfcheck.py run all # the whole course python3 selfcheck.py progress # what you have passed so far python3 selfcheck.py explore # poke at the oracle yourself python3 selfcheck.py reset # clear your progress Your answers stay on your machine, in ``.selfcheck-progress.json``. Nothing is uploaded and nothing is marked. If you want a record of your progress, you export it yourself with ``progress --evidence``. Prefer not to install anything? Each unit's web page has the same checkpoints and runs in a browser. """ from __future__ import annotations import argparse import json import subprocess import sys from datetime import datetime, timezone from fractions import Fraction from pathlib import Path LAB_DIR = Path(__file__).resolve().parent sys.path.insert(0, str(LAB_DIR)) import checkpoints as cp # noqa: E402 import learner_record as lr # noqa: E402 import reference_bernoulli as oracle # noqa: E402 PROGRESS_FILE = LAB_DIR / ".selfcheck-progress.json" RULE = "-" * 70 # -------------------------------------------------------------------------- # Progress # -------------------------------------------------------------------------- def load_progress() -> dict: record, error = lr.load(PROGRESS_FILE) if error: print(f"({error}; progress is not being overwritten)", file=sys.stderr) return record["checkpoints"] def record(checkpoint_id: str, passed: bool, attempts: int, answer: object) -> None: current, error = lr.load(PROGRESS_FILE) if error: print(f"({error}; progress was not saved)", file=sys.stderr) return try: updated = lr.record_checkpoint( current, checkpoint_id, passed=passed, attempts=attempts, answer=answer, ) lr.save(PROGRESS_FILE, updated) except (OSError, ValueError) as exc: print(f"(could not save progress: {exc})", file=sys.stderr) # -------------------------------------------------------------------------- # Presentation # -------------------------------------------------------------------------- def wrap(text: str, width: int = 70, indent: str = "") -> str: import textwrap return "\n".join( textwrap.fill(para, width=width, initial_indent=indent, subsequent_indent=indent) for para in text.split("\n") ) def show_question(checkpoint: cp.Checkpoint) -> None: print(RULE) print(f"{checkpoint.id} (Unit {checkpoint.unit}) - {checkpoint.title}") print(RULE) print(wrap(checkpoint.question)) print() if checkpoint.kind == "match": for i, prompt in enumerate(checkpoint.prompts, 1): print(wrap(f"{i}. {prompt}")) print() print("Options:") for i, option in enumerate(checkpoint.options, 1): print(wrap(f" ({i}) {option}")) print() print(f"Answer with {len(checkpoint.prompts)} option numbers, one per item in the order listed,") print("separated by commas.") elif checkpoint.options: for i, option in enumerate(checkpoint.options, 1): print(wrap(f" ({i}) {option}")) print() if checkpoint.kind == "multi": print("Select all that apply: option numbers separated by commas.") else: print("Answer with one option number.") elif checkpoint.kind == "fraction": print("Answer as an exact fraction, e.g. -2/5") elif checkpoint.kind == "integer": print("Answer with a whole number.") print() # -------------------------------------------------------------------------- # Code checkpoints # -------------------------------------------------------------------------- def run_code_checkpoint(checkpoint: cp.Checkpoint) -> bool: """Run a code checkpoint by delegating to check.py.""" target = LAB_DIR / checkpoint.exercise print(RULE) print(f"{checkpoint.id} (Unit {checkpoint.unit}) - {checkpoint.title}") print(RULE) print(wrap(checkpoint.question)) print() if not target.exists(): print(f"Exercise file not found: {target}") return False source = target.read_text() if "TODO" in source and "raise NotImplementedError" in source: print("Not attempted yet - the stub is still unedited.") print(f"Open {target.relative_to(LAB_DIR)} and complete it, then run this again.") return False result = subprocess.run( [sys.executable, str(LAB_DIR / "check.py"), str(target)], capture_output=True, text=True, cwd=LAB_DIR, ) print(result.stdout.rstrip()) if result.stderr.strip(): print(result.stderr.rstrip(), file=sys.stderr) passed = result.returncode == 0 if passed and checkpoint.explanation: print() print(wrap(checkpoint.explanation)) return passed # -------------------------------------------------------------------------- # Commands # -------------------------------------------------------------------------- def cmd_list(args: argparse.Namespace) -> int: progress = load_progress() print("Self-check checkpoints") print(RULE) current_unit = None for checkpoint in cp.CHECKPOINTS: if checkpoint.unit != current_unit: current_unit = checkpoint.unit print(f"\nUnit {current_unit}") state = progress.get(checkpoint.id, {}) mark = "[x]" if state.get("passed") else "[ ]" # Id, kind and title on one line needed a fixed id column, and ids now # run to thirty characters -- so the columns no longer lined up and the # line ran past eighty, which wraps in a terminal and is clipped in the # printed handbook. Two lines fit any id and any title. print(f" {mark} {checkpoint.id} ({checkpoint.kind})") print(f" {checkpoint.title}") print() print("Run one with: python3 selfcheck.py run ") return 0 def attempt(checkpoint: cp.Checkpoint, supplied: str | None = None) -> bool: """Attempt one checkpoint. Returns True if passed.""" if checkpoint.kind == "code": passed = run_code_checkpoint(checkpoint) record(checkpoint.id, passed, 1, checkpoint.exercise or "") return passed show_question(checkpoint) attempts = 0 max_attempts = 3 while attempts < max_attempts: if supplied is not None: raw = supplied print(f"Your answer: {raw}") else: try: raw = input("Your answer (or 'skip'): ").strip() except (EOFError, KeyboardInterrupt): print("\n(skipped)") return False attempts += 1 if raw.lower() in ("skip", "s", ""): print("(skipped)") record(checkpoint.id, False, attempts, raw) return False passed, message = cp.grade(checkpoint, raw) print() if passed: print("PASS") print(wrap(message)) print() record(checkpoint.id, True, attempts, raw) return True print("Not yet.") print(wrap(message)) print() if supplied is not None: record(checkpoint.id, False, attempts, raw) return False if attempts >= max_attempts: print(f"Three attempts used. Revisit Unit {checkpoint.unit} and come back -") print("this checkpoint will still be here.") record(checkpoint.id, False, attempts, raw) return False return False def cmd_run(args: argparse.Namespace) -> int: if args.unit is not None: selected = cp.by_unit(args.unit) if not selected: print(f"No checkpoints for unit {args.unit}.") return 1 elif args.target in (None, "all"): selected = list(cp.CHECKPOINTS) else: try: selected = [cp.by_id(args.target)] except KeyError: print(f"Unknown checkpoint: {args.target}") print("Run 'python3 selfcheck.py list' to see the ids.") return 1 results = [] for checkpoint in selected: results.append((checkpoint, attempt(checkpoint, args.answer))) if len(results) > 1: passed = sum(1 for _, ok in results if ok) print(RULE) print(f"Session: {passed} of {len(results)} checkpoints passed.") outstanding = [c.id for c, ok in results if not ok] if outstanding: print("Still open: " + ", ".join(outstanding)) print(RULE) return 0 if all(ok for _, ok in results) else 1 def cmd_progress(args: argparse.Namespace) -> int: learner_record, error = lr.load(PROGRESS_FILE) if error: print(error, file=sys.stderr) return 1 progress = learner_record["checkpoints"] total = len(cp.CHECKPOINTS) passed = [c for c in cp.CHECKPOINTS if progress.get(c.id, {}).get("passed")] if args.backup: target = Path(args.backup) try: target.write_text(json.dumps(lr.normalise(learner_record), indent=2) + "\n") except OSError as exc: print(f"Could not write backup: {exc}", file=sys.stderr) return 1 print(f"Portable learner record written to {target}.") return 0 if args.evidence: lines = [ "# Self-check record", "", f"Generated {datetime.now(timezone.utc).strftime('%Y-%m-%d %H:%M UTC')}.", "", f"Passed {len(passed)} of {total} checkpoints.", "", "| checkpoint | unit | passed | attempts |", "|---|---|---|---|", ] for checkpoint in cp.CHECKPOINTS: state = progress.get(checkpoint.id, {}) lines.append( f"| {checkpoint.id} | {checkpoint.unit} | " f"{'yes' if state.get('passed') else 'no'} | " f"{state.get('attempts', 0)} |" ) lines += [ "", "This record shows which checkpoints were passed. It is not a claim that", "the learner can defend the answers; that is what the Unit 17 defence is for.", ] print("\n".join(lines)) return 0 print(f"Passed {len(passed)} of {total} checkpoints.") print() for unit in sorted({c.unit for c in cp.CHECKPOINTS}): unit_checks = cp.by_unit(unit) done = sum(1 for c in unit_checks if progress.get(c.id, {}).get("passed")) bar = "#" * done + "." * (len(unit_checks) - done) print(f" Unit {unit} [{bar}] {done}/{len(unit_checks)}") print() remaining = [c for c in cp.CHECKPOINTS if not progress.get(c.id, {}).get("passed")] if remaining: print(f"Next up: {remaining[0].id} (Unit {remaining[0].unit})") else: print("All checkpoints passed. Unit 17 asks you to defend one of them in writing.") return 0 def cmd_reset(args: argparse.Namespace) -> int: if PROGRESS_FILE.exists(): PROGRESS_FILE.unlink() print("Progress cleared.") else: print("No progress to clear.") return 0 EXPLORE_HELP = """ Commands: b value of the modern B_n e.g. b 30 ada value of Lovelace's B e.g. ada 7 map the Note G index mapping table table [n] modern B_0 .. B_n (default 12) float B_n as an exact fraction and as a float, side by side check run the differential checker on a file help this list quit leave """ def cmd_explore(args: argparse.Namespace) -> int: print("Bernoulli oracle - exploration mode") print(f"Convention: {oracle.CONVENTION}") print(EXPLORE_HELP) while True: try: raw = input("explore> ").strip() except (EOFError, KeyboardInterrupt): print() return 0 if not raw: continue parts = raw.split() command, rest = parts[0].lower(), parts[1:] try: if command in ("quit", "exit", "q"): return 0 if command in ("help", "?"): print(EXPLORE_HELP) elif command == "b": n = int(rest[0]) print(f" B{n} = {oracle.bernoulli(n)}") elif command == "ada": n = int(rest[0]) modern = oracle.ada_to_modern_index(n) print(f" Ada B{n} = modern B{modern} = {oracle.bernoulli(modern)}") elif command == "map": for ada_i, modern_i in sorted(oracle.ADA_NOTE_G_INDEX_MAP.items()): print( f" Ada B{ada_i} -> modern B{modern_i} = " f"{oracle.bernoulli(modern_i)}" ) elif command == "table": upper = int(rest[0]) if rest else 12 for n, value in oracle.bernoulli_sequence(0, upper + 1): print(f" B{n:<3} = {value}") elif command == "float": n = int(rest[0]) exact = oracle.bernoulli(n) print(f" exact B{n} = {exact}") print(f" float B{n} = {float(exact)!r}") if exact != Fraction(float(exact)): print(" -> the float is NOT equal to the exact value.") else: print(" -> this one happens to be exactly representable.") elif command == "check": if not rest: print(" usage: check ") continue subprocess.run( [sys.executable, str(LAB_DIR / "check.py"), rest[0]], cwd=LAB_DIR ) else: print(f" unknown command: {command} (try 'help')") except (IndexError, ValueError) as exc: print(f" {exc}") except RecursionError: print(" that index is too large for the recursive oracle") def build_parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser( prog="selfcheck.py", description="Check your own understanding as you work through the course.", ) sub = parser.add_subparsers(dest="command") p_list = sub.add_parser("list", help="list all checkpoints and your status") p_list.set_defaults(func=cmd_list) p_run = sub.add_parser("run", help="attempt a checkpoint, a unit, or all of them") p_run.add_argument("target", nargs="?", help="checkpoint id, or 'all'") p_run.add_argument("--unit", type=int, help="run every checkpoint in this unit") p_run.add_argument( "--answer", help="supply the answer non-interactively (used by the test suite)", ) p_run.set_defaults(func=cmd_run) p_progress = sub.add_parser("progress", help="show how far you have got") p_progress.add_argument( "--evidence", action="store_true", help="print a Markdown record for your evidence pack", ) p_progress.add_argument( "--backup", metavar="FILE", help="write the portable JSON record for browser import", ) p_progress.set_defaults(func=cmd_progress) p_explore = sub.add_parser("explore", help="query the oracle interactively") p_explore.set_defaults(func=cmd_explore) p_reset = sub.add_parser("reset", help="clear your saved progress") p_reset.set_defaults(func=cmd_reset) return parser def _normalise_argv(argv: list[str]) -> list[str]: """Let ``--answer -1/30`` work as well as ``--answer=-1/30``. Fraction answers start with a minus sign, which argparse would otherwise read as the start of another option. Nobody should have to know that. """ out: list[str] = [] i = 0 while i < len(argv): if argv[i] == "--answer" and i + 1 < len(argv): out.append(f"--answer={argv[i + 1]}") i += 2 continue out.append(argv[i]) i += 1 return out def main(argv: list[str] | None = None) -> int: parser = build_parser() args = parser.parse_args(_normalise_argv(list(argv if argv is not None else sys.argv[1:]))) if not hasattr(args, "func"): parser.print_help() return 0 return args.func(args) if __name__ == "__main__": raise SystemExit(main())