From c266eeb4b5d7be610bbc805555b6b3b1781f10c5 Mon Sep 17 00:00:00 2001 From: vumas3r Date: Thu, 15 Oct 2026 15:01:29 -0600 Subject: [PATCH] Every session against the clock; skip what you know Study mode is gone: no more untimed sessions with the reference solutions open from the start. The path is now objective drills against the clock, then a mock, then the fixed sets, and an objective is done when every task passed on its latest timed attempt. In the lobby "Study mode" becomes "Drill an objective" (the objective list, with one "Against the clock" button per objective); ./ksim study is removed and ./ksim next always drills. study-.json keeps focus, known and the done stamps; results of old study sessions stay in it, read by nothing. Also in this commit, from the work before it: - ./ksim skip / unskip, and links beside each domain in the lobby: mark a domain or an objective as known, so the path, the no-argument drill and drill all leave it out; a mock still draws from every domain - DOMAIN:N picks N tasks of a domain, spread over its objectives - a command with neither --track nor KSIM_TRACK is for the track whose session is under way; ./ksim show says where the task's host resolves - CKAD Q60: the check names which file holds which run's output Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01JZPd3GGWyxTgAkL6a4gAJz --- README.md | 28 ++- cka/curriculum.py | 5 +- ckad/bank_more.py | 2 +- ckad/curriculum.py | 7 +- cks/curriculum.py | 5 +- ksim | 599 ++++++++++++++++++++++++++------------------- web/index.html | 70 +++--- 7 files changed, 417 insertions(+), 299 deletions(-) diff --git a/README.md b/README.md index be93753..7a61def 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,8 @@ terminal, Firefox limited to the documentation the exam allows), a task pane bes run since 2025, and the way killer.sh and sailor.sh present them. It grew out of `cka-sim`: the same task banks, per-check grading, clock, history, weak-spot log, -drills and study path, on a new foundation. +drills and a path through each curriculum, on a new foundation. Every session runs against the +clock. ``` browser ── http://127.0.0.1:8080 ──────────────────────────────────────────────┐ @@ -93,15 +94,21 @@ Firefox in the desktop opens only the allowed documentation for the track (`KSIM lifts that). Submit grades every task on its own host; the hosts stay up for review until the next session starts. -Everything is also available from a terminal: +Everything is also available from a terminal. A command is for the track named with `--track` or +`KSIM_TRACK`; with neither, for the track whose session is under way, and for CKA when there is +none. Task numbers are per track — CKA's task 13 and CKAD's task 13 are different tasks — and a +host name such as `ckad0013` only resolves in the desktop's terminal: from your own, it is +`./ksim ssh 13`. ``` ./ksim --track ckad drill # next least-drilled domain, 5 tasks (--track or KSIM_TRACK) ./ksim drill weak | drill 2 17 20 | drill TSHOOT NET | drill 3.2 ./ksim start [--hard] # full mock, sampled by domain weight ./ksim sim c|d # a fixed simulator set (CKA: c, d · CKAD: a) -./ksim objectives | next | focus # the curriculum as a study path -./ksim study 3.2 | notes 3.2 # an objective: notes, then its tasks with no clock +./ksim objectives | next | focus # the curriculum as a path, one objective at a time against the clock +./ksim notes 3.2 # an objective's notes: what to know, fastest commands, traps, docs +./ksim skip DESIGN | unskip DESIGN # "I am good with this domain (or objective 2.3)": off the path, and back +./ksim drill OBS:3 | next OBS:3 # three tasks of a domain, each from another objective - alone, or added ./ksim list | show N | grade N | solution N | timer | end ./ksim hosts [start] # the session's hosts; `start` brings them back after a reboot ./ksim ssh N # a shell on the host of task N from your own terminal @@ -112,6 +119,15 @@ Everything is also available from a terminal: # then solve it with its script and grade again (--settle N, --keep) ``` +**Skipping what you know.** `./ksim skip DESIGN` (or the "I know this — skip it" link beside a +domain in the browser's objective list) marks a domain or an objective as known: the path, the +no-argument drill and `drill all` leave it out, and it no longer has to be drilled before a full +exam. It is a statement, not a result — nothing is written to the history, the statistics or the +weak list, the objective list shows `»` rather than `✓`, and `./ksim skip` prints what is on record +for it. A mock exam still draws from every domain, as the real one will. To keep such a domain +warm, `DOMAIN:N` takes a few of its tasks, each from another objective: `./ksim drill OBS:3`, or +`./ksim next OBS:3` to add them to the next step on the path. + ## Tracks Each track is a directory: `cka/`, `ckad/`, `cks/`. @@ -135,7 +151,7 @@ Each track is a directory: `cka/`, `ckad/`, `cks/`. **CKA, as audited on 2026-10-13:** all 131 tasks seed cleanly on a host of their own, score nothing when graded untouched 45 seconds after seeding, and reach full marks when their scripted solution is run on the host as `candidate` — for 66 of them that script is the reference solution -exactly as a study session shows it. +exactly as a drill shows it once the task is graded. **CKAD, as audited on 2026-10-12:** all 105 tasks seed cleanly on a host of their own, score nothing when graded untouched 45 seconds after seeding, and reach full marks when their scripted @@ -200,7 +216,7 @@ until a stopped kubelet shows as NotReady; `--keep` leaves the host up to look a In `cka/solve.py`, `AS_WRITTEN` lists the tasks whose reference solution is a script as it stands: for those the audit runs exactly the text the candidate is shown, so a solution that stops working -with a new Kubernetes release fails the next audit instead of misleading you in a study session. +with a new Kubernetes release fails the next audit instead of misleading you after a drill. ## What is different from the real exam diff --git a/cka/curriculum.py b/cka/curriculum.py index ae0bf21..9d08715 100644 --- a/cka/curriculum.py +++ b/cka/curriculum.py @@ -1,11 +1,10 @@ -"""CKA curriculum as a study path: the 27 objectives of the five domains, in the curriculum's own +"""CKA curriculum as a path to the exam: the 27 objectives of the five domains, in the curriculum's own words and order (CKA_Curriculum_v1.35.pdf, cncf/curriculum), each with what to know, the fastest commands, the traps, the documentation pages the exam lets you open, and the tasks that practise it. ./ksim objectives the path, with your progress per objective ./ksim notes 4.4 the notes for one objective (id or key: 4.4, lifecycle) -./ksim study 4.4 those notes, then its tasks with no clock and solutions visible -./ksim drill 4.4 the same tasks against the clock +./ksim drill 4.4 its tasks against the clock ./ksim next whatever is next on the path "tasks" lists every task that really exercises the objective - the plain pool tasks first, then diff --git a/ckad/bank_more.py b/ckad/bank_more.py index fd8a87a..a090eab 100644 --- a/ckad/bank_more.py +++ b/ckad/bank_more.py @@ -575,7 +575,7 @@ eq "$(podman image inspect localhost/%{img}:1.1 --format '{{index .Config.Labels C("the Dockerfile itself sets ENTRYPOINT, USER and LABEL", 1, r""" f="$WS/q60/image/Dockerfile" grep -Eiq '^ENTRYPOINT' "$f" 2>/dev/null && grep -Eiq '^USER[[:space:]]+10001' "$f" && grep -Eiq '^LABEL.*team' "$f" """), - C("default.txt and %{arg2}.txt hold the two outputs", 1, r""" + C("default.txt holds the output of the run with defaults (%{arg}), %{arg2}.txt that of the run with the argument %{arg2}", 1, r""" eq "$(tr -d '\r' < "$WS/q60/default.txt" 2>/dev/null)" "%{img} report: %{arg} (uid 10001)" \ && eq "$(tr -d '\r' < "$WS/q60/%{arg2}.txt" 2>/dev/null)" "%{img} report: %{arg2} (uid 10001)" """), C("localhost/%{img}:1.0 is gone, 1.1 is there", 1, r""" diff --git a/ckad/curriculum.py b/ckad/curriculum.py index 07cd0df..7dfb201 100644 --- a/ckad/curriculum.py +++ b/ckad/curriculum.py @@ -1,14 +1,13 @@ -"""CKAD curriculum as a study path: the 24 objectives of the five domains, in the curriculum's own +"""CKAD curriculum as a path to the exam: the 24 objectives of the five domains, in the curriculum's own words and order (CKAD_Curriculum_v1.37.pdf, cncf/curriculum), each with what to know, the fastest commands, the traps, the documentation pages the exam lets you open, and the tasks that practise it. ./ksim objectives the path, with your progress per objective ./ksim notes 3.2 the notes for one objective (id or key: 3.2, probes) -./ksim study 3.2 those notes, then its tasks with no clock and solutions visible -./ksim drill 3.2 the same tasks against the clock +./ksim drill 3.2 its tasks against the clock "tasks" lists every task that really exercises the objective, most direct first; a task may sit -under several objectives. Study and drill sessions use the pool tasks; the Simulator Set A tasks +under several objectives. Drill sessions use the pool tasks; the Simulator Set A tasks (38-57) are listed too but stay out unless you ask for them (--all), so the mock stays unspoiled. Every task must be listed at least once and every objective needs two pool tasks - `./ksim objectives` says so when that stops being true. diff --git a/cks/curriculum.py b/cks/curriculum.py index d9c37de..aecc815 100644 --- a/cks/curriculum.py +++ b/cks/curriculum.py @@ -1,4 +1,4 @@ -"""CKS curriculum as a study path: the 26 objectives of the six domains, in the curriculum's own +"""CKS curriculum as a path to the exam: the 26 objectives of the six domains, in the curriculum's own words and order, each with what to know, the fastest commands, the traps, the documentation pages the exam lets you open, and the tasks that practise it. @@ -9,8 +9,7 @@ documentation are not on it: for those tools the manual on the day is `--help`. ./ksim --track cks objectives the path, with your progress per objective ./ksim --track cks notes 4.2 the notes for one objective (id or key: 4.2, secrets) -./ksim --track cks study 4.2 those notes, then its tasks with no clock and solutions visible -./ksim --track cks drill 4.2 the same tasks against the clock +./ksim --track cks drill 4.2 its tasks against the clock "tasks" lists every task that really exercises the objective, most direct first; a task may sit under several objectives. Every task must be listed at least once and every objective needs two diff --git a/ksim b/ksim index 242dd42..bb161d6 100755 --- a/ksim +++ b/ksim @@ -2,7 +2,7 @@ """ksim - Kubernetes certification exam simulator (CKA / CKAD / CKS tracks). Hands-on tasks graded per-check, each on its own cluster that you reach with `ssh ` from a remote desktop in the browser - the way the real exams run - with an exam clock, attempt history, -a weak-spot log, drills, and a study path through each curriculum.""" +a weak-spot log, drills, and a path through each curriculum - every session against the clock.""" import concurrent.futures import http.server import importlib @@ -22,7 +22,7 @@ from collections.abc import Callable from datetime import datetime from typing import Any -VERSION = "2026.10.12.1" +VERSION = "2026.10.15.1" ROOT = os.path.dirname(os.path.abspath(__file__)) sys.path.insert(0, ROOT) from sim import desktop, envs # noqa: E402 @@ -44,7 +44,7 @@ PASS_PCT = 0 STATE_FILE = "" HISTORY_FILE = "" WEAK_FILE = "" -STUDY_FILE = "" # tasks solved in study mode (the objective checklist reads it; history never does) +PROGRESS_FILE = "" # study-.json: when each objective was done, your focus, what you marked known OBJECTIVES = [] # the track's curriculum, objective by objective (/curriculum.py), if it has one STUDY_ORDER = [] # domains in the order the path walks them, when that differs from the curriculum's own K8S = "" # Kubernetes minor the track's real exam runs, e.g. "v1.35": picks the exam host image @@ -82,7 +82,7 @@ def load_track(track_id): except ModuleNotFoundError as e: if e.name != f"{track_id}.curriculum": raise - objectives, study_order = [], [] # no objective map for this track: study/drill by domain only + objectives, study_order = [], [] # no objective map for this track: drill by domain only for q in questions: q["weight"] = sum(c["pts"] for c in q["checks"]) q.setdefault("difficulty", "normal") @@ -106,7 +106,7 @@ def load_track(track_id): STATE_FILE=os.path.join(ROOT, ".state", f"state-{track_id}.json"), HISTORY_FILE=os.path.join(ROOT, f"history-{track_id}.jsonl"), WEAK_FILE=os.path.join(ROOT, f"weak-spots-{track_id}.md"), - STUDY_FILE=os.path.join(ROOT, f"study-{track_id}.json"), OBJECTIVES=objectives, STUDY_ORDER=study_order, + PROGRESS_FILE=os.path.join(ROOT, f"study-{track_id}.json"), OBJECTIVES=objectives, STUDY_ORDER=study_order, K8S=track["k8s"], PENDING=pending, ) @@ -223,11 +223,29 @@ ensure_metrics_server() { } """ +def _session_tracks(): + """The tracks that have a session under way (a state file that has not been ended).""" + out = [] + for t in TRACKS: + try: + with open(os.path.join(STATE_DIR, f"state-{t}.json")) as f: + if not json.load(f).get("ended_at"): + out.append(t) + except (OSError, ValueError): + pass + return out + def _pick_track_arg(argv): - t = os.environ.get("KSIM_TRACK", "cka") + """The track a command is for: --track, else $KSIM_TRACK, else the one track with a session under + way - after `./ksim --track ckad drill`, a plain `./ksim show 13` means that drill's task 13, + not CKA's - else cka.""" if "--track" in argv: i = argv.index("--track"); t = argv[i + 1]; del argv[i:i + 2] - return t + return t + if os.environ.get("KSIM_TRACK"): + return os.environ["KSIM_TRACK"] + running = _session_tracks() + return running[0] if len(running) == 1 else "cka" load_track(_pick_track_arg(ARGV)) @@ -237,6 +255,7 @@ def c(code, s): return f"\033[{code}m{s}\033[0m" if TTY else s def green(s): return c("32", s) def red(s): return c("31", s) def yellow(s): return c("33", s) +def cyan(s): return c("36", s) def bold(s): return c("1", s) def dim(s): return c("2", s) class Die(SystemExit): @@ -268,14 +287,9 @@ def paused_total(st: dict[str, Any] | None) -> float: t += time.time() - float(st["paused_at"]) return t -def untimed(st: dict[str, Any] | None) -> bool: - return bool(st and st.get("untimed")) - def remaining(st: dict[str, Any] | None) -> float: if not st: return 0.0 - if untimed(st): - return float("inf") return float(st.get("started_at", 0)) + float(st.get("duration", EXAM_SECONDS)) + paused_total(st) - time.time() def set_paused(st: dict[str, Any], on: bool) -> None: @@ -572,6 +586,47 @@ def pick(domain, n, hard, rng, exclude=(), taken=(), unseen_only=False): got = no_conflicts(build(True), n, taken) return got +_TOUCH = re.compile(r"([A-Za-z]+):(\d+)") + +def touch_arg(a): + """DOMAIN:N as (domain, n), or None if the argument is something else.""" + m = _TOUCH.fullmatch(a) + return (m.group(1).upper(), int(m.group(2))) if m and m.group(1).upper() in DOMAINS else None + +def spot_check(domain, n, hard, rng, taken=()): + """A few tasks of one domain, spread over its objectives - to touch a domain rather than work + through it (./ksim drill OBS:3): one task from each objective in turn, starting with the + objectives you have solved least of, and within one the tasks you have not solved, then the + least recently seen. Three tasks therefore come from three objectives, and the next three from + the others.""" + taken = list(taken); out = [] + objs = [o for o in OBJECTIVES if o["domain"] == domain] + if not objs: + return pick(domain, n, hard, rng, exclude=[q["id"] for q in taken], taken=taken) + agg, seen = stats(), last_seen() + rng.shuffle(objs) + objs.sort(key=lambda o: sum(1 for q in objective_tasks(o) if task_solved(q["id"], agg))) + def cands(o, allow_hard): + qs = [q for q in objective_tasks(o) if q["domain"] == domain and not q.get("solo") and q not in taken and q not in out + and (allow_hard or q["difficulty"] == "normal")] + rng.shuffle(qs) + qs.sort(key=lambda q: (task_solved(q["id"], agg), seen.get(q["id"], -1))) + return qs + cover = {o["id"]: 0 for o in objs} # tasks taken so far that each objective lists (a task may sit under two) + for allow_hard in ((True,) if hard else (False, True)): # normal tasks first; hard ones only to fill up + while len(out) < n: + for o in sorted(objs, key=lambda o: cover[o["id"]]): # the objective with least so far; ties keep the order above + got = no_conflicts(cands(o, allow_hard), 1, taken + out) + if got: + out += got + for other in objectives_of(got[0]["id"]): + if other["id"] in cover: + cover[other["id"]] += 1 + break + else: + break # no objective has a task left + return out + def compose_exam(hard, rng): quota = QUOTA_HARD if hard else QUOTA # Scarcest domain first. Two tasks that "breaks" the same thing can't share a session, so a @@ -596,8 +651,9 @@ def compose_exam(hard, rng): def campaign_batch(hard, rng): """'drill all': DRILL_SIZE tasks you have never attempted, round-robin across domains, hard tasks included when --hard. When everything has been seen once, falls back to weak tasks.""" - seen = last_seen() - pool = [q for q in POOL if q["id"] not in seen and not q.get("solo") and (hard or q["difficulty"] == "normal")] + seen = last_seen(); known = load_known() + left = [q for q in POOL if (hard or q["difficulty"] == "normal") and not known_task(q, known)] # what you marked known is not "left" + pool = [q for q in left if q["id"] not in seen and not q.get("solo")] if not pool: return None, [] qs, domains = [], list(DOMAINS) @@ -614,12 +670,12 @@ def campaign_batch(hard, rng): break if not progressed: break - total = len([q for q in POOL if hard or q["difficulty"] == "normal"]) + total = len(left) return f"{total - len(pool)}/{total} attempted", qs def solution_allowed(st: dict[str, Any] | None, qid: int | str) -> bool: - """Study: always. Drill: once the task has been graded. Exam: only after submit.""" - if not st or st.get("ended_at") or untimed(st): + """Drill: once the task has been graded. Exam: only after submit.""" + if not st or st.get("ended_at"): return True return bool(st.get("mode") == "drill" and str(qid) in st.get("results", {})) @@ -710,8 +766,8 @@ def write_weak_spots(): # --------------------------------------------------------- curriculum ---- # A track may ship tracks//curriculum.py: the exam's objectives in order, each with study -# notes and the tasks that practise it. It turns the bank into a path - study an objective with -# the solutions open, then prove it against the clock - and `./ksim objectives` shows where you are. +# notes and the tasks that practise it. It turns the bank into a path - drill one objective at a +# time against the clock until every task passes - and `./ksim objectives` shows where you are. def find_objective(arg): """An objective by id (3.2) or key (probes, or any unambiguous start of it); None if none fits.""" a = str(arg).lower() @@ -748,99 +804,98 @@ def path_objectives(): order = path_domains() return sorted(OBJECTIVES, key=lambda o: order.index(o["domain"])) # stable sort -def objective_session(o, how, with_sets=False, n=None): - """The tasks of one sitting on an objective: what is still open first ("study": never solved; - "drill": not yet passed against the clock), at most n, never two that break the same thing. - A big objective therefore takes several sittings, each on new ground.""" - agg, studied = stats(), load_study() - closed = ((lambda q: task_solved(q["id"], agg, studied)) if how == "study" - else (lambda q: task_state(q["id"], agg, studied) == "passed")) +def objective_session(o, with_sets=False, n=None): + """The tasks of one sitting on an objective: what has not yet passed against the clock first, + at most n, never two that break the same thing. A big objective therefore takes several + sittings, each on new ground.""" + agg = stats() qs = objective_tasks(o, with_sets) - todo = [q for q in qs if not closed(q)] or qs # nothing open: go over all of it again + todo = [q for q in qs if task_state(q["id"], agg) != "passed"] or qs # nothing open: go over all of it again return no_conflicts(todo, n or DRILL_SIZE), len(todo) def load_progress(): """study-.json, next to the history so it survives ./teardown.sh as the history does: - tasks task id -> its latest result in a study session ("passed" sticks once earned) - objectives objective id -> when it was first studied and first completed + objectives objective id -> when it was first done focus domains the path walks first, in that order (./ksim focus) - Study results are kept apart from the history on purpose: a pass with the solution open is - practice, not proof, so it never reaches the statistics, the weak list or the exam gate.""" + known domains and objectives you declared known, which the path leaves out (./ksim skip) + tasks what the study sessions of earlier versions solved - kept as it was, read by nothing. + Results against the clock live in the history; this file only steers the path.""" try: - with open(STUDY_FILE) as f: + with open(PROGRESS_FILE) as f: d = json.load(f) except (OSError, ValueError): d = {} - return {"tasks": {int(k): v for k, v in d.get("tasks", {}).items()}, "objectives": dict(d.get("objectives", {})), - "focus": [x for x in d.get("focus", []) if x in DOMAINS]} + oids = {o["id"] for o in OBJECTIVES} + return {"tasks": dict(d.get("tasks", {})), "objectives": dict(d.get("objectives", {})), + "focus": [x for x in d.get("focus", []) if x in DOMAINS], + "known": [x for x in dict.fromkeys(d.get("known", [])) if x in DOMAINS or x in oids]} def save_progress(prog): - with open(STUDY_FILE, "w") as f: - json.dump({"tasks": {str(k): prog["tasks"][k] for k in sorted(prog["tasks"])}, + with open(PROGRESS_FILE, "w") as f: + json.dump({**({"tasks": prog["tasks"]} if prog.get("tasks") else {}), "objectives": {k: prog["objectives"][k] for k in sorted(prog["objectives"])}, - **({"focus": prog["focus"]} if prog.get("focus") else {})}, f, indent=1) + **({"focus": prog["focus"]} if prog.get("focus") else {}), + **({"known": prog["known"]} if prog.get("known") else {})}, f, indent=1) f.write("\n") -def load_study(): - return load_progress()["tasks"] +def load_known(): + """What you declared known with ./ksim skip: domain codes and objective ids, in the order you + marked them. A statement, not a result - it steers the path and nothing else.""" + return load_progress()["known"] -def record_study(st, ids=None): - """Note what a study session graded (all of it, or just `ids`).""" - if not untimed(st): - return - prog = load_progress(); log = prog["tasks"] - for k, r in st.get("results", {}).items(): - if ids is not None and int(k) not in ids: - continue - log[int(k)] = {"at": datetime.now().isoformat(timespec="minutes"), "pass": r["pass"], "earned": r["earned"], - "total": r["total"], "passed": bool(r["pass"] or log.get(int(k), {}).get("passed"))} - save_progress(prog) +def is_known(o, known): + """An objective you declared known, by itself or with its whole domain.""" + return o["domain"] in known or o["id"] in known -def task_state(qid, agg, studied): +def known_task(q, known): + """A task the path no longer brings up: its domain is marked known, or every objective that + practises it is.""" + if q["domain"] in known: + return True + objs = objectives_of(q["id"]) + return bool(objs) and all(is_known(o, known) for o in objs) + +def task_state(qid, agg): """passed = the latest attempt against the clock was a full pass; failed = it was not; - studied = solved in study mode but never attempted against the clock; new = neither.""" + new = never attempted.""" a = agg.get(int(qid)) if a is not None and a.attempts: return "passed" if a.last else "failed" - return "studied" if studied.get(int(qid), {}).get("passed") else "new" + return "new" -def task_solved(qid, agg, studied): - """Solved at least once, with the solution open or against the clock.""" +def task_solved(qid, agg): + """Passed against the clock at least once, if not necessarily on the latest attempt.""" a = agg.get(int(qid)) - return bool(studied.get(int(qid), {}).get("passed") or (a is not None and a.attempts > a.fails)) + return bool(a is not None and a.attempts > a.fails) -MARKS = {"passed": ("✓", green), "studied": ("~", yellow), "failed": ("✗", red), "new": ("·", dim), "started": ("-", yellow)} +MARKS = {"passed": ("✓", green), "failed": ("✗", red), "new": ("·", dim), "started": ("-", yellow), "known": ("»", cyan)} def mark(state): ch, col = MARKS[state] return col(ch) -def objective_state(o, agg, studied): - """The two stages every objective goes through, and where it is: - studied every pool task solved at least once (study mode counts) +def objective_state(o, agg, known=None): + """Where an objective is: done every pool task passed against the clock on its latest attempt - exam conditions - before that: started (something attempted) or new.""" + before that: started (something attempted) or new. And beside them: + known you said you are good with it (./ksim skip), so the path leaves it out. Proof still + outranks the statement: a known objective you did complete shows as done.""" qs = objective_tasks(o) - states = [task_state(q["id"], agg, studied) for q in qs] + states = [task_state(q["id"], agg) for q in qs] if qs and all(x == "passed" for x in states): return "done" - if qs and all(task_solved(q["id"], agg, studied) for q in qs): - return "studied" + if is_known(o, load_known() if known is None else known): + return "known" return "started" if any(x != "new" for x in states) else "new" -OBJECTIVE_MARK = {"done": "passed", "studied": "studied", "started": "started", "new": "new"} +OBJECTIVE_MARK = {"done": "passed", "started": "started", "new": "new", "known": "known"} -def next_objective(agg=None, studied=None): - """(objective, how) for the first objective in curriculum order that is not done: "study" it - until every task has been solved once, then "drill" it against the clock. (None, None) when - every objective is done.""" +def next_objective(agg=None): + """The first objective in path order that is neither done nor marked known - drilled against + the clock until every task passes. None when nothing is left.""" agg = stats() if agg is None else agg - studied = load_study() if studied is None else studied - for o in path_objectives(): - state = objective_state(o, agg, studied) - if state != "done": - return o, ("drill" if state == "studied" else "study") - return None, None + known = load_known() + return next((o for o in path_objectives() if objective_state(o, agg, known) not in ("done", "known")), None) def mock_scores(): """Scores of the full mocks and of each fixed simulator set, from the history.""" @@ -853,23 +908,22 @@ def mock_scores(): (fixed[hit] if hit else full).append(e["score_pct"]) return full, fixed -def next_step(agg=None, studied=None): +def next_step(agg=None): """The next move on the path to the exam, as {kind, cmd, why[, objective | set]}: - study an objective -> drill it against the clock -> ... every objective -> a full mock -> - the fixed simulator sets. Each stage is closer to the real exam than the one before.""" + drill an objective against the clock -> ... every objective -> a full mock -> the fixed + simulator sets. Each stage is closer to the real exam than the one before.""" agg = stats() if agg is None else agg - studied = load_study() if studied is None else studied - o, how = next_objective(agg, studied) - if o and how == "study": - return {"kind": "study", "objective": o, "cmd": f"./ksim study {o['id']}", - "why": f"{o['id']} {o['text']} - notes, then its tasks with no clock and the solutions open"} + o = next_objective(agg) if o: return {"kind": "drill", "objective": o, "cmd": f"./ksim drill {o['id']}", - "why": f"{o['id']} {o['text']} - studied; now the same tasks against the clock, no solution until graded"} + "why": f"{o['id']} {o['text']} - its tasks against the clock, no solution until graded"} full, fixed = mock_scores() if not any(x >= PASS_PCT for x in full): + skipped = any(is_known(x, load_known()) for x in OBJECTIVES) return {"kind": "mock", "cmd": "./ksim start", - "why": f"every objective is done - a full mock: {sum(QUOTA.values())} tasks, {TRACK['minutes']} minutes, no feedback until you submit"} + "why": f"every objective is done{' or marked known' if skipped else ''} - a full mock: {sum(QUOTA.values())} tasks, " + f"{TRACK['minutes']} minutes, no feedback until you submit" + + (" (it draws from every domain, the known ones included)" if skipped else "")} for k in SETS: if not any(x >= PASS_PCT for x in fixed[k]): return {"kind": "sim", "set": k, "cmd": f"./ksim sim {k}", @@ -878,40 +932,27 @@ def next_step(agg=None, studied=None): "why": f"every objective done and every mock passed at {PASS_PCT}% or better - keep sharp with a fresh mock"} def update_milestones(): - """Stamp each objective the first time it is studied and the first time it is done, and - return what was newly reached as [(objective, "studied" | "done")]. Called whenever results - are recorded, so the checklist keeps the date you got there.""" + """Stamp each objective the first time it is done, and return the objectives newly done. + Called whenever results are recorded, so the checklist keeps the date you got there.""" if not OBJECTIVES: return [] prog = load_progress(); agg = stats(); new = [] now = datetime.now().isoformat(timespec="minutes") for o in OBJECTIVES: - state = objective_state(o, agg, prog["tasks"]) - seen = prog["objectives"].setdefault(o["id"], {}) - for level in ("studied", "done"): - if state in (level, "done") and level not in seen: - seen[level] = now - new.append((o, level)) - if not seen: - del prog["objectives"][o["id"]] + if objective_state(o, agg, prog["known"]) == "done" and "done" not in prog["objectives"].get(o["id"], {}): + prog["objectives"].setdefault(o["id"], {})["done"] = now + new.append(o) if new: save_progress(prog) - # reaching "done" in one go also stamps "studied"; only the higher one is news - return [(o, lv) for o, lv in new if not (lv == "studied" and (o, "done") in new)] + return new def milestone_lines(new): - out = [] - for o, level in new: - n = len(objective_tasks(o)) - if level == "done": - out.append(f"✓ Objective {o['id']} {o['key']} complete - {n}/{n} tasks passed against the clock.") - else: - out.append(f"~ Objective {o['id']} {o['key']} studied - every task solved once. Prove it against the clock: ./ksim drill {o['id']}") - return out + return [f"✓ Objective {o['id']} {o['key']} complete - {len(objective_tasks(o))}/{len(objective_tasks(o))} tasks passed against the clock." + for o in new] def curriculum_gaps(): """What would make the path lie: a task no objective lists, a listed id that does not exist, - an objective too thin to study.""" + an objective too thin to drill.""" listed = {i for o in OBJECTIVES for i in o["tasks"]} out = [] orphans = [q["id"] for q in QUESTIONS if q["id"] not in listed] @@ -931,20 +972,20 @@ def curriculum_gaps(): def require_objectives(): if not OBJECTIVES: die(f"Track '{TRACK_ID}' has no objective map (tracks/{TRACK_ID}/curriculum.py).\n" - f" Study it by domain instead: ./ksim study {next(iter(DOMAINS))}") + f" Drill it by domain instead: ./ksim drill {next(iter(DOMAINS))}") def cmd_objectives(args): """The curriculum as a checklist: every objective, its tasks, and how far you are.""" require_objectives() - agg, studied = stats(), load_study() + agg, known = stats(), load_known() cols = shutil.get_terminal_size((120, 24)).columns sets = len(QUESTIONS) - len(POOL) - print(bold(f"{TRACK['name']} - study path") + dim(f" {len(OBJECTIVES)} objectives · {len(POOL)} tasks" - + (f" · {sets} more in the fixed sets [in brackets]" if sets else ""))) - print(dim("tasks: ✓ passed against the clock ~ solved in study mode ✗ failed last time · not yet" - " objectives: ✓ complete ~ studied - started")) + print(bold(f"{TRACK['name']} - path") + dim(f" {len(OBJECTIVES)} objectives · {len(POOL)} tasks" + + (f" · {sets} more in the fixed sets [in brackets]" if sets else ""))) + print(dim("tasks: ✓ passed against the clock ✗ failed last time · not yet" + " objectives: ✓ complete - started » marked known, off the path")) def cell(q): - return f"{MARKS[task_state(q['id'], agg, studied)][0]}{q['id']}", f"{mark(task_state(q['id'], agg, studied))}{q['id']}" + return f"{MARKS[task_state(q['id'], agg)][0]}{q['id']}", f"{mark(task_state(q['id'], agg))}{q['id']}" rows = [] for o in OBJECTIVES: pool = [cell(q) for q in objective_tasks(o)] @@ -955,32 +996,34 @@ def cmd_objectives(args): tw = max(28, min(max(len(o["text"]) for o in OBJECTIVES), cols - 26 - max(len(r[1]) for r in rows))) focus = load_focus() for d in objective_domains(): - print(bold(f"\n{DOMAINS[d]}") + (yellow(f" focus {focus.index(d) + 1}") if d in focus else "")) + print(bold(f"\n{DOMAINS[d]}") + (yellow(f" focus {focus.index(d) + 1}") if d in focus else "") + + (cyan(f" marked known - off the path (./ksim unskip {d} brings it back)") if d in known else "")) for o, plain, shown in rows: if o["domain"] != d: continue - state = objective_state(o, agg, studied) + state = objective_state(o, agg, known) text = o["text"] if len(o["text"]) <= tw else o["text"][:tw - 1] + "…" print(f" {mark(OBJECTIVE_MARK[state])} {o['id']:<4}{o['key']:<16}{text:<{tw}} {shown}") - print_path(agg, studied) + print_path(agg) for g in curriculum_gaps(): print(yellow(f"curriculum.py: {g}")) -def print_path(agg=None, studied=None): - """The ladder from first reading to exam conditions, how far up you are, and the next rung.""" +def print_path(agg=None): + """The ladder from one objective at a time to exam conditions, how far up you are, and the next rung.""" agg = stats() if agg is None else agg - studied = load_study() if studied is None else studied - states = [objective_state(o, agg, studied) for o in OBJECTIVES] - n, done = len(OBJECTIVES), states.count("done") + known = load_known() + states = [objective_state(o, agg, known) for o in OBJECTIVES] + skipped = states.count("known") + n, done = len(OBJECTIVES) - skipped, states.count("done") + aside = dim(f" + {skipped} marked known") if skipped else "" full, fixed = mock_scores() - step = next_step(agg, studied) + step = next_step(agg) def best(scores): if not scores: return dim("not sat yet") b = max(scores) return (green if b >= PASS_PCT else red)(f"best {b}%") + dim(f" of {len(scores)}, pass mark {PASS_PCT}%") - rows = [("study", "Study", "each objective's tasks, notes and solutions open", f"{done + states.count('studied')}/{n} objectives"), - ("drill", "Clock", "the same tasks as a timed drill, graded per check", f"{done}/{n} objectives")] + rows = [("drill", "Clock", "each objective's tasks, timed, graded per check", f"{done}/{n} objectives{aside}")] rows.append(("mock", "Mock exam", f"{sum(QUOTA.values())} random tasks, {TRACK['minutes']} min, no feedback until submit", best(full))) rows += [("sim", f"Set {k.upper()}", f"{len(set_questions(k))} fixed tasks, {TRACK['minutes']} min, exam rules", best(fixed[k])) for k in SETS] print(bold("\nPath to the exam")) @@ -990,19 +1033,19 @@ def print_path(agg=None, studied=None): focus = load_focus() if focus: print(dim(f"\nFocus: {', '.join(focus)} first, then the rest of the curriculum (./ksim focus shows it, ./ksim focus clear undoes it)")) + if known: + print(dim(f"\nKnown: {', '.join(known)} - left out of the path because you said so (./ksim skip shows it, ./ksim unskip X brings X back)")) print(bold(f"\nNext: {step['cmd']}") + dim(" (or just ./ksim next)")) print(dim(f" {step['why']}")) if step.get("objective"): print(dim(f" ./ksim notes {step['objective']['id']} what to know, the fastest commands, the traps, the docs to open")) def cmd_next(args): - """Start whatever is next on the path: study an objective, drill it, a mock, a simulator set.""" + """Start whatever is next on the path: drill an objective, a mock, a simulator set.""" require_objectives() step = next_step() print(bold(f"Next on the path: {step['cmd']}") + dim(f"\n {step['why']}")) - if step["kind"] == "study": - cmd_study([step["objective"]["id"]] + args) - elif step["kind"] == "drill": + if step["kind"] == "drill": cmd_drill([step["objective"]["id"]] + args) elif step["kind"] == "sim": cmd_sim([step["set"]] + args) @@ -1012,7 +1055,7 @@ def cmd_next(args): def cmd_focus(args): """Put domains at the front of the path - the ones an exam report or a mock said are weakest: ./ksim focus DEPLOY NET ENV, ./ksim focus clear, ./ksim focus (show). Nothing else changes: a - mock still samples every domain, and any objective can still be studied at any time.""" + mock still samples every domain, and any objective can still be drilled at any time.""" prog = load_progress(); before = list(prog["focus"]) if args: if args[0].lower() in ("clear", "none", "off"): @@ -1031,30 +1074,123 @@ def cmd_focus(args): print("No focus set - the path follows " + ("the track's study order." if STUDY_ORDER else "the curriculum's order.") + dim(f"\n ./ksim focus {' '.join(list(DOMAINS)[:2])} puts those domains first")) return - agg, studied = stats(), prog["tasks"] + agg = stats() print(bold("Focus") + dim(" these domains come first on the path, in this order")) for i, d in enumerate(focus, 1): objs = [o for o in OBJECTIVES if o["domain"] == d] - states = [objective_state(o, agg, studied) for o in objs] + states = [objective_state(o, agg) for o in objs] pool = [q for q in POOL if q["domain"] == d] - solved = sum(1 for q in pool if task_solved(q["id"], agg, studied)) - passed = sum(1 for q in pool if task_state(q["id"], agg, studied) == "passed") - where = (f"objectives {states.count('done') + states.count('studied')}/{len(objs)} studied, {states.count('done')}/{len(objs)} on the clock · " - if objs else "") - print(f" {i} {DOMAINS[d]:<58}" + dim(f"{where}tasks {solved}/{len(pool)} solved, {passed}/{len(pool)} passed on the clock")) + solved = sum(1 for q in pool if task_solved(q["id"], agg)) + passed = sum(1 for q in pool if task_state(q["id"], agg) == "passed") + where = f"objectives {states.count('done')}/{len(objs)} done · " if objs else "" + print(f" {i} {DOMAINS[d]:<58}" + dim(f"{where}tasks {solved}/{len(pool)} passed once, {passed}/{len(pool)} on the latest try")) if OBJECTIVES: - step = next_step(agg, studied) + step = next_step(agg) print(bold(f"\nNext: {step['cmd']}") + dim(" (or just ./ksim next)") + dim(f"\n {step['why']}")) else: - print(dim("\n./ksim drill and ./ksim study with no argument pick these first among the domains you have drilled least.")) + print(dim("\n./ksim drill with no argument picks these first among the domains you have drilled least.")) + +def _known_what(x): + """A known entry as text: a domain with its objectives and tasks, or one objective.""" + if x in DOMAINS: + objs = [o for o in OBJECTIVES if o["domain"] == x] + return f"{x:<8}{DOMAINS[x]} - {len(objs)} objective(s), {len([q for q in POOL if q['domain'] == x])} pool tasks" + o = find_objective(x) + return f"{o['id']:<8}{o['text']} ({o['domain']})" + +def set_known(mark=(), unmark=(), clear=False): + """Change what is marked known and return (known, added, removed). Entries are domain codes or + objective ids; a domain covers its objectives, so marking it drops their own entries, and + unmarking one objective of a known domain leaves the others of that domain marked.""" + prog = load_progress(); before = list(prog["known"]); known = [] if clear else list(before) + for x in unmark: + if x in known: + known.remove(x) + elif x not in DOMAINS: + o = find_objective(x) + if o and o["domain"] in known: # one objective back on the path: its siblings stay known + i = known.index(o["domain"]) + known[i:i + 1] = [p["id"] for p in OBJECTIVES if p["domain"] == o["domain"] and p["id"] != o["id"]] + for x in mark: + if x in DOMAINS: + known = [k for k in known if k == x or k in DOMAINS or find_objective(k)["domain"] != x] + if x not in known: + known.append(x) + elif x not in known and find_objective(x)["domain"] not in known: + known.append(x) + prog["known"] = known + prog["focus"] = [d for d in prog["focus"] if d not in known] # a domain cannot be first on the path and off it + if known != before or len(prog["focus"]) != len(load_focus()): + save_progress(prog) + return known, [k for k in known if k not in before], [k for k in before if k not in known] + +def _known_args(args, cmd): + out = [] + for a in args: + if a.upper() in DOMAINS: + out.append(a.upper()) + elif find_objective(a): + out.append(find_objective(a)["id"]) + else: + die(f"'{a}' is not a domain or an objective of the {TRACK_ID.upper()} track. Domains:\n " + + "\n ".join(f"{d:<8}{name}" for d, name in DOMAINS.items()) + + f"\n objectives: ./ksim objectives e.g. ./ksim {cmd} {next(iter(DOMAINS))} ./ksim {cmd} {OBJECTIVES[0]['id'] if OBJECTIVES else '1.1'}") + return list(dict.fromkeys(out)) + +def print_known(known): + if not known: + print("Nothing is marked known - the path walks the whole curriculum." + + dim(f"\n ./ksim skip {next(iter(DOMAINS))} leaves that domain out because you are good with it")) + return + agg = stats() + print(bold("Marked known") + dim(" left out of the path because you said so - a statement, not a result")) + for x in known: + pool = [q for q in POOL if (q["domain"] == x if x in DOMAINS else q["id"] in find_objective(x)["tasks"])] + solved = sum(1 for q in pool if task_solved(q["id"], agg)) + passed = sum(1 for q in pool if task_state(q["id"], agg) == "passed") + print(f" {cyan('»')} {_known_what(x)}" + dim(f" on record: {solved}/{len(pool)} tasks passed once, {passed}/{len(pool)} on the latest try")) + print(dim(" Nothing is written to the history, the statistics or the weak list. A mock exam still draws from every\n" + " domain, as the real one will, and ./ksim drill X still works for anything.\n" + " ./ksim unskip X puts X back on the path ./ksim skip clear puts everything back")) + +def cmd_skip(args): + """Mark domains or objectives as known - "I am good with this" - so the path leaves them out: + ./ksim skip DESIGN, ./ksim skip 2.3 kustomize, ./ksim skip (show), ./ksim skip clear.""" + if args and args[0].lower() in ("clear", "none", "off"): + known, _, removed = set_known(clear=True) + if removed: + print(f"Back on the path: {', '.join(removed)}") + elif args: + known, added, _ = set_known(mark=_known_args(args, "skip")) + for x in added: + print(f"Marked known: {_known_what(x)}") + else: + known = load_known() + print_known(known) + if OBJECTIVES and (args or known): + step = next_step() + print(bold(f"\nNext: {step['cmd']}") + dim(" (or just ./ksim next)") + dim(f"\n {step['why']}")) + +def cmd_unskip(args): + """Put domains or objectives you marked known back on the path: ./ksim unskip DESIGN 2.3, ./ksim unskip all.""" + if not args: + die("usage: ./ksim unskip DOMAIN|OBJECTIVE.. or ./ksim unskip all (./ksim skip shows what is marked known)") + if args[0].lower() == "all": + known, _, removed = set_known(clear=True) + else: + known, _, removed = set_known(unmark=_known_args(args, "unskip")) + print(f"Back on the path: {', '.join(removed)}" if removed else "Nothing changed - that was not marked known.") + print_known(known) + if OBJECTIVES: + step = next_step() + print(bold(f"\nNext: {step['cmd']}") + dim(" (or just ./ksim next)") + dim(f"\n {step['why']}")) def paint_code(text): """`code` in a note: highlighted on a terminal, left as written (it is Markdown) in a pipe.""" return re.sub(r"`([^`]+)`", lambda m: bold(m.group(1)), text) if TTY else text -def print_notes(o, agg=None, studied=None): +def print_notes(o, agg=None): agg = stats() if agg is None else agg - studied = load_study() if studied is None else studied w = min(shutil.get_terminal_size((100, 24)).columns, 104) def bullets(items): for it in items: @@ -1069,7 +1205,7 @@ def print_notes(o, agg=None, studied=None): print(" " + u) print(bold("\nTasks")) for q in objective_tasks(o, True): - print(f" {mark(task_state(q['id'], agg, studied))} Q{q['id']:<3} {q['title']}" + (dim(f" [{SETS.get(q['set'], q['set'])}]") if q.get("set") else "")) + print(f" {mark(task_state(q['id'], agg))} Q{q['id']:<3} {q['title']}" + (dim(f" [{SETS.get(q['set'], q['set'])}]") if q.get("set") else "")) print("-" * 64) def notes_markdown(objs): @@ -1079,15 +1215,15 @@ def notes_markdown(objs): "the fastest commands, the traps, the documentation pages the exam lets you open, and the tasks in this " "simulator that practise each one.", "", f"_Generated by `./ksim --track {TRACK_ID} notes all --md` from `tracks/{TRACK_ID}/curriculum.py` - edit that file, not this one._", "", - "```", f"./ksim objectives # the path and your progress", "./ksim study 3.2 # one objective: these notes, then its tasks, no clock, solutions open", - "./ksim drill 3.2 # the same tasks against the clock", "```", ""] + "```", f"./ksim objectives # the path and your progress", "./ksim notes 3.2 # one objective's notes", + "./ksim drill 3.2 # its tasks against the clock", "```", ""] for d in objective_domains(): mine = [o for o in objs if o["domain"] == d] if not mine: continue L += [f"## {DOMAINS[d]}", ""] for o in mine: - L += [f"### {o['id']} {o['text']}", "", f"`./ksim study {o['id']}` (or `{o['key']}`)", "", "**Know**", ""] + L += [f"### {o['id']} {o['text']}", "", f"`./ksim drill {o['id']}` (or `{o['key']}`)", "", "**Know**", ""] L += [f"- {x}" for x in o["know"]] L += ["", "**Fast**", "", "```", o["fast"].strip("\n"), "```", "", "**Traps**", ""] L += [f"- {x}" for x in o["traps"]] @@ -1104,7 +1240,7 @@ def cmd_notes(args): if not rest: st = load_state() o = find_objective(st.get("objective")) if st and not st.get("ended_at") and st.get("objective") else None - objs = [o or next_objective()[0] or OBJECTIVES[0]] + objs = [o or next_objective() or OBJECTIVES[0]] elif rest[0] == "all": objs = list(OBJECTIVES) elif rest[0].upper() in DOMAINS: @@ -1116,13 +1252,13 @@ def cmd_notes(args): + "\n ".join(f"{o['id']:<5}{o['key']:<16}{o['text']}" for o in OBJECTIVES)) if "--md" in args: print(notes_markdown(objs)); return - agg, studied = stats(), load_study() + agg = stats() for i, o in enumerate(objs): if i: print() - print_notes(o, agg, studied) + print_notes(o, agg) if len(objs) == 1: - print(dim(f"./ksim study {objs[0]['id']} its tasks, no clock, solutions open ./ksim drill {objs[0]['id']} against the clock")) + print(dim(f"./ksim drill {objs[0]['id']} its tasks against the clock")) # ------------------------------------------------------------ session ---- def preflight(qs): @@ -1213,8 +1349,7 @@ def open_sessions_elsewhere(): pass return out -def begin_session(qs, mode, duration, force, log: Callable[..., Any] = _log_msg, hard=False, untimed_session=False, other_ok=None, - objective=None): +def begin_session(qs, mode, duration, force, log: Callable[..., Any] = _log_msg, hard=False, other_ok=None, objective=None): st = load_state() if st and not st.get("ended_at") and not force: die("A session is already in progress. './ksim end' it first, or add --force.") @@ -1249,27 +1384,23 @@ def begin_session(qs, mode, duration, force, log: Callable[..., Any] = _log_msg, clear_hosts() die("Could not create every host (see above), so the session was not started.\n" " Usual causes: not enough memory, or podman's limits - try fewer tasks or KSIM_PARALLEL=3.") - st = {"mode": mode, "track": TRACK_ID, "hard": hard, "untimed": untimed_session, "ids": [q["id"] for q in qs], + st = {"mode": mode, "track": TRACK_ID, "hard": hard, "ids": [q["id"] for q in qs], "params": params_by_id, "duration": duration, "started_at": time.time(), "ended_at": None, "k8s": k8s_version(), "results": {}, "flags": [], "notes": "", - "objective": objective} # the curriculum objective a study/drill session was started for, if any + "objective": objective} # the curriculum objective a drill was started for, if any save_state(st) log("") log(bold("=" * 64)) - kind = "STUDY" if untimed_session else ("EXAM" if mode == "exam" else "DRILL") - log(bold(f" {TRACK_ID.upper()} {kind}{' · HARD' if hard else ''} - {'no clock, solutions open' if untimed_session else 'clock started'}")) + log(bold(f" {TRACK_ID.upper()} {'EXAM' if mode == 'exam' else 'DRILL'}{' · HARD' if hard else ''} - clock started")) log(f" Hosts : {hosts[0]}" + (f" .. {len(hosts)} in all" if len(hosts) > 1 else "") + f" (Kubernetes {st['k8s']}, as the real {TRACK_ID.upper()} runs {K8S})") - if not untimed_session: - log(f" Ends : {datetime.fromtimestamp(st['started_at'] + duration).strftime('%H:%M:%S')} ({duration // 60} min)") - log(f" Tasks : {', '.join('Q%02d' % q['id'] for q in qs) if mode == 'drill' else len(qs)}" - + ("" if untimed_session else f" Pass mark: {PASS_PCT}%")) + log(f" Ends : {datetime.fromtimestamp(st['started_at'] + duration).strftime('%H:%M:%S')} ({duration // 60} min)") + log(f" Tasks : {', '.join('Q%02d' % q['id'] for q in qs) if mode == 'drill' else len(qs)} Pass mark: {PASS_PCT}%") if problems: log(yellow(f" Seeding had problems on: {', '.join(problems)} - ./ksim reset N rebuilds one task's host")) log(bold("=" * 64)) log(" ./ksim web the exam in the browser: task pane, remote desktop, clock\n" - + (" ./ksim list / show N / grade N / solution N / end terminal workflow\n" if untimed_session else - " ./ksim list / show N / grade N / timer / end terminal workflow\n") + " ./ksim list / show N / grade N / timer / end terminal workflow\n" + f" Every task names its host: ssh {hosts[0]} from the desktop's terminal, then sudo -i when you need root.\n" + f" Files a task asks for go under {WS_DISPLAY}/ on its host.") return st @@ -1289,11 +1420,7 @@ def finish_session(st, log: Callable[..., Any] = _log_msg): st["ended_at"] = time.time() st["over_time"] = over save_state(st) - entry = None - if untimed(st): - record_study(st) - else: - entry = record_attempt(st) + entry = record_attempt(st) # objectives this session got over a line: shown by ./ksim end and by the results page st["milestones"] = st.get("milestones", []) + milestone_lines(update_milestones()) save_state(st) @@ -1302,7 +1429,8 @@ def finish_session(st, log: Callable[..., Any] = _log_msg): # ----------------------------------------------------------- commands ---- def cmd_start(args): hard = "--hard" in args; force = "--force" in args - undrilled = [d for d, n in drilled_domains().items() if n == 0] + known = load_known() # a domain you marked known does not have to be drilled first + undrilled = [d for d, n in drilled_domains().items() if n == 0 and d not in known] if undrilled and not force: die("Drill every domain at least once before a full exam (that is the point of the drills).\n" f" Not yet drilled: {', '.join(undrilled)}\n Run: ./ksim drill (picks the next domain)\n" @@ -1313,7 +1441,9 @@ def next_drill(hard, rng): """No-arg drill: the least-drilled domain, DRILL_SIZE tasks, least-recently-seen first.""" cov = drilled_domains() order = list(dict.fromkeys([*load_focus(), *DOMAINS])) # equally drilled: your focus first - domain = min(DOMAINS, key=lambda d: (cov[d], order.index(d))) + known = load_known() + doms = [d for d in DOMAINS if d not in known] or list(DOMAINS) # not the domains you marked known + domain = min(doms, key=lambda d: (cov[d], order.index(d))) return domain, pick(domain, DRILL_SIZE, hard, rng) def cmd_drill(args): @@ -1351,18 +1481,21 @@ def cmd_drill(args): for a in args: if a.lower().replace("set", "") in SETS and a.lower().startswith("set"): ids += [q["id"] for q in set_questions(a.lower().replace("set", ""))] + elif touch_arg(a): + d, k = touch_arg(a) + ids += [q["id"] for q in spot_check(d, k, hard, random.Random(), taken=[q for q in QUESTIONS if q["id"] in ids])] elif a.upper() in DOMAINS: ids += [q["id"] for q in QUESTIONS if q["domain"] == a.upper()] elif a.isdigit(): ids.append(q_by_id(int(a))["id"]) elif find_objective(a): - sitting, open_n = objective_session(find_objective(a), "drill") + sitting, open_n = objective_session(find_objective(a)) ids += [q["id"] for q in sitting] objective = objective or find_objective(a)["id"] if open_n > len(sitting): print(dim(f" {find_objective(a)['id']}: {len(sitting)} of its {open_n} open tasks in this sitting - run it again for the rest.")) else: - die(f"'{a}' is not a task id, a domain ({', '.join(DOMAINS)}), a set " + die(f"'{a}' is not a task id, a domain ({', '.join(DOMAINS)}; {next(iter(DOMAINS))}:3 = three of its tasks), a set " f"({', '.join('set' + k for k in SETS) or 'none'})" + (" or an objective (./ksim objectives)." if OBJECTIVES else ".")) ids = sorted(set(ids)) @@ -1381,50 +1514,6 @@ def cmd_drill(args): begin_session(kept, "drill", (minutes * 60) if minutes else per_task * len(kept), force, hard=hard, objective=objective if len(args) == 1 else None) -def cmd_study(args): - """No clock, solutions unlocked from the start, nothing written to history.""" - hard = "--hard" in args; force = "--force" in args; with_sets = "--all" in args - rest = [a for a in args if not a.startswith("--")] - n = DRILL_SIZE - if "--count" in args: - n = int(args[args.index("--count") + 1]); rest = [a for a in rest if a != str(n)] - rng = random.Random() - obj = None - if rest and rest[0].upper() in DOMAINS: - domain = rest[0].upper() - qs = pick(domain, n, hard, rng) - print(bold(f"Study: {DOMAINS[domain]}")) - elif rest and rest[0] == "weak": - qs = no_conflicts(split_solo([q for q in QUESTIONS if q["id"] in weak_ids()])[0], n) - print(bold("Study: tasks you have been failing")) - elif OBJECTIVES and (not rest or rest[0] == "next" or find_objective(rest[0])): - # one objective of the curriculum: the one named, else the next one on the path - if rest and rest[0] != "next": - obj = find_objective(rest[0]) - else: - agg, studied = stats(), load_study() - obj = next((o for o in path_objectives() if objective_state(o, agg, studied) in ("new", "started")), None) - if obj is None: - die("Every objective has been studied - name one to revisit (./ksim objectives lists them),\n" - f" or carry on up the path: {next_step(agg, studied)['cmd']} (./ksim next)") - qs, open_n = objective_session(obj, "study", with_sets, n if "--count" in args else None) - print(bold(f"Study: {obj['id']} {obj['text']}") + dim(f" {DOMAINS[obj['domain']]}")) - if open_n > len(qs): - print(dim(f" {len(qs)} of its {open_n} open tasks in this sitting - run the same command again for the rest.")) - elif rest: - qs = no_conflicts(split_solo([q_by_id(int(a)) if a.isdigit() else die( - f"'{a}' is not a task id, a domain ({', '.join(DOMAINS)}), 'weak'" - + (" or an objective (./ksim objectives)." if OBJECTIVES else ".")) for a in rest])[0], len(rest)) - print(bold("Study: " + ", ".join(f"Q{q['id']}" for q in qs))) - else: - domain, qs = next_drill(hard, rng) - print(bold(f"Study: {DOMAINS[domain]}")) - begin_session(qs, "drill", EXAM_SECONDS, force, hard=hard, untimed_session=True, objective=obj and obj["id"]) - if obj: - print(); print_notes(obj) - print(dim(" ./ksim show N read a task ./ksim grade N check it ./ksim solution N reference answer\n" - " ./ksim notes these notes again ./ksim end finish (./ksim objectives then shows what you solved)")) - def cmd_sim(args): """A fixed simulator set, full exam rules and clock.""" force = "--force" in args @@ -1463,7 +1552,15 @@ def cmd_show(args): sq = session_questions(st0) if st0 and not st0.get("ended_at") else [] pos = f"{sq.index(q) + 1} of {len(sq)} | " if q in sq else "" print(bold(f"{pos}Q{q['id']} | {DOMAINS[q['domain']]} | {q['weight']} pts{' | HARD' if q['difficulty'] == 'hard' else ''}")) - print(bold(q["title"])); print(f"Solve this question on: ssh {host_of(q)}"); print("-" * 64); print(task_text(q)); print("-" * 64) + print(bold(q["title"])); print(f"Solve this question on: ssh {host_of(q)}") + # The host's name only resolves on the simulator's own network, and only while the task is in a session. + if q in sq: + print(dim(f" (in the desktop's terminal - from this one: ./ksim --track {TRACK_ID} ssh {q['id']})")) + else: + others = [t for t in _session_tracks() if t != TRACK_ID] + print(dim(f" (no {TRACK_ID.upper()} session holds this task, so that host does not exist yet: ./ksim --track {TRACK_ID} drill {q['id']} starts one" + + (f" - the session under way is {others[0].upper()}'s: ./ksim --track {others[0]} show N" if others else "") + ")")) + print("-" * 64); print(task_text(q)); print("-" * 64) st = load_state() if st and not st.get("ended_at"): print(status_line(st)) @@ -1484,13 +1581,6 @@ def cmd_grade(args): tag = green("PASS") if r.get("pass") else yellow(f"{r.get('earned')}/{r.get('total')}") print(f"Q{n:02d} {tag} {q['title']}"); print(dim(result_detail(r))) save_state(st) - record_study(st, set(ids)) - if untimed(st): - reached = milestone_lines(update_milestones()) - if reached: # kept with the session, so ./ksim end and the results page show them again - st["milestones"] = st.get("milestones", []) + reached; save_state(st) - for line in reached: - print(green(line)) if args[0] == "all": print_score(st) @@ -1509,8 +1599,6 @@ def print_score(st): print(f"\n{bold('Score:')} {earned}/{total} = {pct}% -> {verdict} (pass mark {PASS_PCT}%)") def status_line(st): - if untimed(st): - return green("study mode - no clock") + dim(f" [{len(session_questions(st))} tasks, kept out of the history]") r = remaining(st) if st.get("paused_at"): return yellow(f"⏸ PAUSED - {fmt(r)} remaining") + dim(" ./ksim resume") @@ -1530,8 +1618,6 @@ def cmd_timer(args): st = require_started() if st.get("ended_at"): die("Session already ended.") - if untimed(st): - die("This is a study session - there is no clock.") try: while True: st = load_state(); r = remaining(st) @@ -1576,8 +1662,6 @@ def cmd_end(args): print(green(line)) step = next_step() print(bold(f"Next on the path: {step['cmd']}") + dim(f" {step['why']}")) - if entry is None: - print(dim("Study session - not added to history or weak-spots.")); path_note(); return print(dim(f"Time used: {fmt(st['ended_at'] - st['started_at'] - paused_total(st))}" + (" (over time)" if st.get("over_time") else ""))) failed = sorted(int(k) for k, v in entry["results"].items() if not v["pass"]) print(dim(f"Attempt #{len(load_history())} logged to {os.path.basename(HISTORY_FILE)}; {os.path.basename(WEAK_FILE)} updated.")) @@ -1697,6 +1781,12 @@ class Api(http.server.BaseHTTPRequestHandler): load_track(t) Api.log_lines = [] return self._json(self.session_payload()) + if p.path == "/api/known": # mark a domain or an objective as known, or put it back on the path + x = str(body.get("mark", "")) + if x not in DOMAINS and not any(o["id"] == x for o in OBJECTIVES): + return self._json({"error": f"unknown domain or objective {x}"}, 400) + set_known(mark=[x]) if body.get("known", True) else set_known(unmark=[x]) + return self._json(self.session_payload()) if p.path == "/api/clip": # a value clicked in a task lands on the desktop's clipboard desktop.clip(str(body.get("text", ""))[:20000]) return self._json({"ok": True}) @@ -1714,9 +1804,11 @@ class Api(http.server.BaseHTTPRequestHandler): elif mode == "exam": begin_session(compose_exam(hard, rng), "exam", EXAM_SECONDS, True, log=ulog, hard=hard, other_ok=False) else: + # touch: [[domain, n]..] - a few tasks of a domain, on top of the rest or on their own + touch = [(str(t[0]).upper(), int(t[1])) for t in (body.get("touch") or []) if str(t[0]).upper() in DOMAINS] objective = find_objective(body["objective"]) if body.get("objective") else None if objective: - qs, _ = objective_session(objective, "study" if body.get("study") else "drill", bool(body.get("all"))) + qs, _ = objective_session(objective, bool(body.get("all"))) elif body.get("domain") in DOMAINS: qs = pick(body["domain"], DRILL_SIZE, hard, rng) elif body.get("campaign"): @@ -1725,12 +1817,15 @@ class Api(http.server.BaseHTTPRequestHandler): qs = no_conflicts(split_solo([q for q in QUESTIONS if q["id"] in weak_ids()])[0], DRILL_SIZE) elif body.get("next"): _, qs = next_drill(hard, rng) + elif touch and not body.get("ids"): + qs = [] else: ids = body.get("ids") or weak_ids() qs = no_conflicts(split_solo([q for q in QUESTIONS if q["id"] in ids])[0], len(ids)) + for d, n in touch: + qs = list(qs) + spot_check(d, n, hard, rng, taken=qs) per = DRILL_SECONDS_PER_TASK_HARD if hard else DRILL_SECONDS_PER_TASK - begin_session(qs, "drill", per * len(qs), True, log=ulog, hard=hard, - untimed_session=bool(body.get("study")), other_ok=False, + begin_session(qs, "drill", per * len(qs), True, log=ulog, hard=hard, other_ok=False, objective=objective and objective["id"]) except SystemExit as e: Api.seed_error = (getattr(e, "msg", None) or "\n".join(l for l in Api.log_lines if l.strip()) @@ -1757,9 +1852,6 @@ class Api(http.server.BaseHTTPRequestHandler): if "results" not in st or not isinstance(st["results"], dict): st["results"] = {} st["results"][str(q["id"])] = grade_one(q, session_params(st, q)); save_state(st) - record_study(st, {q["id"]}) - if untimed(st): - st["milestones"] = st.get("milestones", []) + milestone_lines(update_milestones()); save_state(st) elif p.path == "/api/end": if not st.get("ended_at"): down = hosts_down(st) # grading hosts that are not running would record zeros @@ -1799,7 +1891,7 @@ class Api(http.server.BaseHTTPRequestHandler): "ends_at": st["started_at"] + st.get("duration", EXAM_SECONDS), "ended_at": st.get("ended_at"), "over_time": st.get("over_time", False), "flags": st.get("flags", []), "paused": bool(st.get("paused_at")), "paused_total": paused_total(st), - "untimed": untimed(st), "notes": st.get("notes", ""), + "notes": st.get("notes", ""), "objective": st.get("objective"), "milestones": st.get("milestones", []), "results": st["results"], "score": {"earned": earned, "total": total, "pct": pct, "pass_pct": PASS_PCT}, "domains": self.domain_scores(st), "server_time": time.time()} @@ -1807,18 +1899,18 @@ class Api(http.server.BaseHTTPRequestHandler): def path_payload(self) -> dict[str, Any]: """The curriculum as the UI shows it: every objective with its notes, tasks and state, how - many are studied and done, and the next step on the path. Empty for a track without one.""" + many are done, and the next step on the path. Empty for a track without one.""" if not OBJECTIVES: - return {"objectives": [], "focus": [], "path": None} - agg, studied = stats(), load_study() - objs = [{"id": o["id"], "key": o["key"], "domain": o["domain"], "text": o["text"], "state": objective_state(o, agg, studied), + return {"objectives": [], "focus": [], "known": load_known(), "path": None} + agg, known = stats(), load_known() + objs = [{"id": o["id"], "key": o["key"], "domain": o["domain"], "text": o["text"], "state": objective_state(o, agg, known), "know": o["know"], "fast": o["fast"].strip("\n"), "traps": o["traps"], "docs": o["docs"], - "tasks": [{"id": q["id"], "title": q["title"], "set": q.get("set"), "state": task_state(q["id"], agg, studied)} + "tasks": [{"id": q["id"], "title": q["title"], "set": q.get("set"), "state": task_state(q["id"], agg)} for q in objective_tasks(o, True)]} for o in OBJECTIVES] - step = next_step(agg, studied); full, fixed = mock_scores() + step = next_step(agg); full, fixed = mock_scores() states = [o["state"] for o in objs] - return {"objectives": objs, "focus": load_focus(), - "path": {"total": len(objs), "done": states.count("done"), "studied": states.count("done") + states.count("studied"), + return {"objectives": objs, "focus": load_focus(), "known": known, + "path": {"total": len(objs), "known": states.count("known"), "done": states.count("done"), "mock_best": max(full) if full else None, "set_best": {k: (max(v) if v else None) for k, v in fixed.items()}, "next": {"kind": step["kind"], "cmd": step["cmd"], "why": step["why"], "set": step.get("set"), "objective": step["objective"]["id"] if step.get("objective") else None}}} @@ -1972,7 +2064,7 @@ def cmd_coverage(args): print(col(f"{o['id']:<6}{o['domain']:<8}{len(pool):>5}{len(ts) - len(pool):>6} {o['text']}")) print(dim(" a task may sit under several objectives, so these do not add up to the domain totals")) else: - print(yellow(f"\nNo {TRACK_ID}/curriculum.py yet: no objectives, study notes or study path for this track.")) + print(yellow(f"\nNo {TRACK_ID}/curriculum.py yet: no objectives, notes or path for this track.")) for i, why in PENDING.items(): print(dim(f" held back: Q{i} - {why}")) @@ -2076,7 +2168,7 @@ def cmd_audit(args): print(dim(f" report: {os.path.relpath(path, ROOT)}")) def cmd_down(args): - """Delete every exam host and the desktop. History, study progress and images are kept.""" + """Delete every exam host and the desktop. History, path progress and images are kept.""" for t in TRACKS: try: with open(os.path.join(STATE_DIR, f"state-{t}.json")) as f: @@ -2094,11 +2186,12 @@ def cmd_help(args): print(__doc__ + f"\nversion {VERSION}") objective_help = "" if not OBJECTIVES else ( f" objectives the curriculum as a path to the exam: {len(OBJECTIVES)} objectives, their tasks, your progress\n" - " next start the next step on that path (study an objective, then drill it, then the mocks)\n" + " next start the next step on that path (drill an objective against the clock, then the mocks)\n" " focus [DOMAIN..|clear] walk these domains first on the path - the ones an exam report said are weakest\n" + " skip [DOMAIN|OBJ..] mark a domain or an objective as known: the path leaves it out, nothing is recorded\n" + " as passed, and a mock still draws from it (skip clear / unskip X bring it back)\n" " notes [OBJ|DOMAIN|all] what to know, fastest commands, traps and docs for an objective (3.2 or probes)\n" - " study [OBJ|next] an objective's notes and tasks; with no argument, the next one on the path\n" - " (--all adds its Simulator Set tasks); drill OBJ = the same tasks against the clock\n") + " drill OBJ an objective's tasks against the clock, those not yet passed first\n") print(f"""usage: ./ksim [--track cka|ckad|cks] (current track: {TRACK_ID}, {len(QUESTIONS)} tasks) Every task runs on its own cluster at the exam's Kubernetes version ({TRACK_ID}: {k8s_version()}), reached with `ssh ` from the remote desktop. @@ -2106,11 +2199,12 @@ def cmd_help(args): build [TRACK|all] build the exam host and desktop images (once per Kubernetes minor) build cks --vm also build the VM exam host that the Falco and AppArmor tasks need (about ten minutes) web [--port 8080] the exam in the browser: task pane, remote desktop, clock, submit - study [DOMAIN|weak|N..] no clock, solutions unlocked, kept out of the history (--count N, default {DRILL_SIZE}) {objective_help} drill next domain you have drilled least: {DRILL_SIZE} tasks, 6 min each (--hard: 5 min, hard tasks) drill all [--hard] campaign: {DRILL_SIZE} never-attempted tasks per session until the bank is done, then weak ones drill weak|N..|DOMAIN.. chosen tasks (--time MIN, --force, --hard) - progress attempted/passed per domain, normal and hard + drill .. DOMAIN:3 three tasks of that domain, spread over its objectives - on their own, or added to + whatever else you named: ./ksim next OBS:3 + progress attempted/passed per domain, normal and hard solution N reference solution (drills: after grading N; exams: after submit) sim {'|'.join(SETS) or ''}{' ' * max(1, 20 - len('|'.join(SETS)))}fixed simulator set, exam rules, {TRACK['minutes']} min start [--hard] full exam: {sum(QUOTA.values()) or '?'} tasks sampled by domain ({sum(QUOTA_HARD.values()) or '?'} in hard mode), {TRACK['minutes']} min. @@ -2135,9 +2229,10 @@ def cmd_help(args): COMMANDS = {"start": cmd_start, "drill": cmd_drill, "list": cmd_list, "show": cmd_show, "grade": cmd_grade, "status": cmd_status, "timer": cmd_timer, "pause": cmd_pause, "resume": cmd_resume, "end": cmd_end, "weak": cmd_weak, "history": cmd_history, - "reset": cmd_reset, "web": cmd_web, "abort": cmd_abort, "progress": cmd_progress, "solution": cmd_solution, "sim": cmd_sim, "study": cmd_study, "version": cmd_version, "help": cmd_help, + "reset": cmd_reset, "web": cmd_web, "abort": cmd_abort, "progress": cmd_progress, "solution": cmd_solution, "sim": cmd_sim, "version": cmd_version, "help": cmd_help, "build": cmd_build, "hosts": cmd_hosts, "ssh": cmd_ssh, "down": cmd_down, "audit": cmd_audit, "coverage": cmd_coverage, - "objectives": cmd_objectives, "curriculum": cmd_objectives, "notes": cmd_notes, "next": cmd_next, "focus": cmd_focus} + "objectives": cmd_objectives, "curriculum": cmd_objectives, "notes": cmd_notes, "next": cmd_next, "focus": cmd_focus, + "skip": cmd_skip, "known": cmd_skip, "unskip": cmd_unskip} if __name__ == "__main__": cmd = ARGV[0] if ARGV else "help" diff --git a/web/index.html b/web/index.html index 74766cb..bfd0795 100644 --- a/web/index.html +++ b/web/index.html @@ -150,7 +150,9 @@ details.obj[open] > summary { margin-bottom: 6px; } details.obj .oid { font-family: var(--mono); color: var(--muted); } .mk { font-family: var(--mono); font-weight: 600; text-align: center; } - .st-done, .st-passed { color: var(--ok); } .st-studied, .st-started { color: var(--warn); } .st-failed { color: var(--danger); } .st-new { color: var(--muted); } + .st-done, .st-passed { color: var(--ok); } .st-started, .st-focus { color: var(--warn); } .st-failed { color: var(--danger); } .st-new { color: var(--muted); } .st-known { color: var(--accent-hover); } + #objList h3 a.known, #objList h3 a.touch { text-transform: none; letter-spacing: 0; font-weight: 400; margin-left: 8px; } + .pill.known { color: var(--accent-hover); } .chips { font: 12px var(--mono); white-space: nowrap; color: var(--muted); } .chips span { margin-left: 6px; } .ladder { display: grid; grid-template-columns: auto 1fr auto; gap: 2px 12px; font-size: 13px; margin: 0 0 14px; padding: 10px 12px; @@ -206,14 +208,14 @@
-
Drill a domain (✓ = drilled at least once):
+
Drill a domain (✓ = drilled at least once, » = marked known):
- - + + @@ -292,7 +294,7 @@ } // ---- curriculum ---- - const MARK = {done: '✓', passed: '✓', studied: '~', started: '-', failed: '✗', new: '·'}; + const MARK = {done: '✓', passed: '✓', started: '-', failed: '✗', new: '·', known: '»'}; const mk = st => `${MARK[st] || '·'}`; const inline = s => esc(s).replace(/`([^`]+)`/g, '$1'); function notesHtml(o) { @@ -303,21 +305,19 @@ } function continuePath() { const n = S.path && S.path.next; if (!n) return; - if (n.kind === 'study') start('drill', null, false, false, null, true, n.objective); - else if (n.kind === 'drill') start('drill', null, false, false, null, false, n.objective); + if (n.objective) start('drill', null, false, false, null, n.objective); else if (n.kind === 'sim') startSim(n.set); else start('exam'); } function paintPath() { const P = S.path, has = !!P; $('#optPath').classList.toggle('hidden', !has); $('#ladder').classList.toggle('hidden', !has); - $('#optStudy').querySelector('b').textContent = has ? 'Study an objective' : 'Study mode'; - $('#optStudy').querySelector('span').textContent = has - ? `Pick any of the ${P.total} objectives: its notes, then its tasks with no clock and the solutions visible.` - : '5 tasks, no clock, reference solutions visible from the start. Nothing scored or recorded.'; + $('#optObjective').classList.toggle('hidden', !has); if (!has) return; + $('#objectiveDesc').textContent = `Pick any of the ${P.total} objectives: read its notes, then drill its tasks against the clock.`; const best = v => v == null ? 'not sat yet' : `best ${v}%`, k = P.next.kind; - const rows = [['study', 'Study', `${P.studied}/${P.total} objectives`], ['drill', 'Against the clock', `${P.done}/${P.total} objectives`], + const kn = P.known || 0, left = P.total - kn, aside = kn ? ` · ${kn} marked known` : ''; + const rows = [['drill', 'Against the clock', `${P.done}/${left} objectives${aside}`], ['mock', 'Mock exam', best(P.mock_best)]].concat(Object.keys(S.sets || {}).map(x => ['sim:' + x, S.sets[x].name, best(P.set_best[x])])); $('#ladder').innerHTML = rows.map(([kind, name, where], i) => { const here = kind === k || kind === 'sim:' + P.next.set && k === 'sim'; @@ -330,15 +330,26 @@ const chips = o => o.tasks.filter(t => !t.set).map(chip).join('') + (o.tasks.some(t => t.set) ? `[${o.tasks.filter(t => t.set).map(chip).join('').replace(']` : ''); const doms = [...new Set(S.objectives.map(o => o.domain))]; - $('#objSub').textContent = '✓ complete: every task passed against the clock. ~ studied. - started. Open an objective for its notes.'; + $('#objSub').textContent = '✓ complete: every task passed against the clock. - started. » marked known, off the path. Open an objective for its notes.'; const focus = S.focus || []; // ./ksim focus: the domains the path walks first - $('#objList').innerHTML = doms.map(d => `

${esc(S.track.domains[d])}${focus.includes(d) ? ` · focus ${focus.indexOf(d) + 1}` : ''}

` + S.objectives.filter(o => o.domain === d).map(o => + const known = S.known || []; // ./ksim skip: what you said you know - the path leaves it out, nothing is recorded as passed + const knownTag = d => known.includes(d) + ? ` · known, off the path3 of its questionsput it back` + : `I know this — skip it`; + $('#objList').innerHTML = doms.map(d => `

${esc(S.track.domains[d])}${focus.includes(d) ? ` · focus ${focus.indexOf(d) + 1}` : ''}${knownTag(d)}

` + S.objectives.filter(o => o.domain === d).map(o => `
${mk(o.state)}${o.id}${esc(o.text)}${chips(o)}
${notesHtml(o)}
-
+
`).join('')).join(''); - $('#objList').querySelectorAll('[data-study]').forEach(b => b.onclick = () => start('drill', null, false, false, null, true, b.dataset.study)); - $('#objList').querySelectorAll('[data-drill]').forEach(b => b.onclick = () => start('drill', null, false, false, null, false, b.dataset.drill)); + $('#objList').querySelectorAll('[data-drill]').forEach(b => b.onclick = () => start('drill', null, false, false, null, b.dataset.drill)); + $('#objList').querySelectorAll('a.touch').forEach(a => a.onclick = e => { + e.preventDefault(); start('drill', null, false, false, null, null, [[a.dataset.touch, 3]]); + }); + $('#objList').querySelectorAll('a.known').forEach(a => a.onclick = async e => { + e.preventDefault(); + try { await api('/api/known', {mark: a.dataset.known, known: a.dataset.on === '1'}); await load(); } + catch (err) { alert(err.message === 'not found' ? 'This ./ksim web was started before "skip" existed. Stop it (Ctrl-C) and start it again.' : err.message); } + }); } function qResultClass(q) { @@ -350,11 +361,10 @@ function paint() { const sess = S.session, live = sess && !sess.ended_at; $('#trackName').textContent = `${S.track.name} · Kubernetes ${S.track.k8s}`; - $('#modeLabel').textContent = sess ? (sess.untimed ? 'Study' : (sess.mode === 'drill' ? 'Drill' : 'Exam')) + (sess.hard ? ' · HARD' : '') + $('#modeLabel').textContent = sess ? (sess.mode === 'drill' ? 'Drill' : 'Exam') + (sess.hard ? ' · HARD' : '') + (sess.objective ? ' · objective ' + sess.objective : '') : ''; $('#btnEnd').classList.toggle('hidden', !live); - if (live && sess.untimed) $('#btnEnd').textContent = 'End study session'; - $('#btnPause').classList.toggle('hidden', !live || sess.untimed); + $('#btnPause').classList.toggle('hidden', !live); if (live) $('#btnPause').textContent = sess.paused ? 'Resume' : 'Pause'; $('#btnCheck').classList.toggle('hidden', !(live && sess.mode === 'drill')); $('#lobby').classList.toggle('hidden', !!live || (sess && sess.ended_at && !$('#results').classList.contains('hidden'))); @@ -418,7 +428,6 @@ clearInterval(clockT); const sess = S.session, el = $('#clock'); if (!sess || sess.ended_at) { el.textContent = sess ? 'ended' : '--:--:--'; el.className = ''; return; } - if (sess.untimed) { el.textContent = 'study'; el.className = 'paused'; return; } const step = () => { const now = Date.now()/1000 - skew; const pausedTotal = sess.paused_total + (sess.paused ? (now - sess.server_time) : 0); @@ -438,11 +447,13 @@ const hard = $('#hardMode').checked, qn = hard ? (S.track.quota_hard || {}) : (S.track.quota || {}); const total = Object.values(qn).reduce((a, b) => a + b, 0) || S.bank.length; $('#examDesc').textContent = `${total} tasks sampled across the ${Object.keys(S.track.domains).length} domains, ${S.track.minutes} minutes, pass mark ${S.track.pass_pct}%. No feedback until you submit.`; - const drilled = S.drilled || {}, undone = Object.keys(S.track.domains).filter(d => !drilled[d]); + const drilled = S.drilled || {}, skipped = S.known || []; + const undone = Object.keys(S.track.domains).filter(d => !drilled[d] && !skipped.includes(d)); $('#coverage').innerHTML = Object.keys(S.track.domains).map(d => - `${d} ${drilled[d] ? '✓' : '·'}`).join(''); + `${d} ${drilled[d] ? '✓' : skipped.includes(d) ? '»' : '·'}`).join(''); $('#coverage').querySelectorAll('[data-domain]').forEach(el => el.onclick = () => start('drill', null, false, false, el.dataset.domain)); - const nxt = Object.keys(S.track.domains).sort((a, b) => (drilled[a] || 0) - (drilled[b] || 0))[0]; + const openDoms = Object.keys(S.track.domains).filter(d => !skipped.includes(d)); + const nxt = (openDoms.length ? openDoms : Object.keys(S.track.domains)).sort((a, b) => (drilled[a] || 0) - (drilled[b] || 0))[0]; const eligible = S.bank.filter(q => hard || q.difficulty !== 'hard'), seenN = eligible.filter(q => (S.seen || []).includes(q.id)).length; $('#allDesc').textContent = `${seenN}/${eligible.length} tasks attempted. 5 unseen tasks per session, round-robin across domains${hard ? ', hard first' : ''}; then whatever you failed.`; $('#nextDesc').textContent = `${S.track.domains[nxt]} — 5 tasks, ${hard ? 5 : 6} min each. Drills rotate through all domains before the exam.`; @@ -489,12 +500,12 @@ } finally { $('#seeding').classList.add('hidden'); } } - async function start(mode, ids, next, campaign, domain, study, objective) { + async function start(mode, ids, next, campaign, domain, objective, touch) { $('#lobbyOpts').classList.add('hidden'); $('#drillPick').classList.add('hidden'); $('#objPick').classList.add('hidden'); $('#lobbyErr').classList.add('hidden'); $('#seeding').classList.remove('hidden'); $('#seedLog').textContent = 'starting…'; try { - await api('/api/start', {mode, ids, next: !!next, campaign: !!campaign, domain: domain || null, study: !!study, - objective: objective || null, hard: $('#hardMode').checked}); + await api('/api/start', {mode, ids, next: !!next, campaign: !!campaign, domain: domain || null, + objective: objective || null, touch: touch || null, hard: $('#hardMode').checked}); // seeding runs in the background; stream its log until it finishes for (;;) { await new Promise(r => setTimeout(r, 1200)); @@ -526,7 +537,7 @@ function showResults() { const s = S.session, sc = s.score; $('#results').dataset.shown = 1; - $('#resSub').textContent = `${s.untimed ? 'Study session' : (s.mode === 'drill' ? 'Drill' : 'Full exam')} · ${fmt(s.ended_at - s.started_at - s.paused_total)} used${s.over_time ? ' (over time)' : ''}`; + $('#resSub').textContent = `${s.mode === 'drill' ? 'Drill' : 'Full exam'} · ${fmt(s.ended_at - s.started_at - s.paused_total)} used${s.over_time ? ' (over time)' : ''}`; $('#resScore').innerHTML = `${sc.pct}% ${sc.earned} / ${sc.total} points`; const v = $('#resVerdict'); v.textContent = sc.pct >= sc.pass_pct ? 'Pass' : 'Fail'; v.className = 'verdict ' + (sc.pct >= sc.pass_pct ? 'pass' : 'fail'); $('#resDomains').innerHTML = Object.entries(s.domains).map(([d, x]) => @@ -556,8 +567,7 @@ // ---- wiring ---- $('#optExam').onclick = () => start('exam'); $('#optNext').onclick = () => start('drill', null, true); - $('#optStudy').onclick = () => { - if (!S.path) return start('drill', null, true, false, null, true); + $('#optObjective').onclick = () => { $('#lobbyOpts').classList.add('hidden'); $('#gate').classList.add('hidden'); $('#objPick').classList.remove('hidden'); }; $('#optPath').onclick = continuePath;