mentar 0.1.0.dev0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
mentar/__init__.py ADDED
@@ -0,0 +1,6 @@
1
+ """Mentar — OSS-first, local-first AI tutor for children.
2
+
3
+ See docs/SPEC.md for the project specification and docs/ARCHITECTURE.md for module layout.
4
+ """
5
+
6
+ __version__ = "0.1.0.dev0"
mentar/cli/__init__.py ADDED
@@ -0,0 +1 @@
1
+ """Mentar CLI entry: `mentar serve`, `mentar eval`, `mentar validate-template`."""
mentar/cli/__main__.py ADDED
@@ -0,0 +1,62 @@
1
+ """CLI entry point. Wired in pyproject.toml [project.scripts].
2
+
3
+ Subcommands:
4
+ serve — Start a pilot tutoring session (stub).
5
+ eval — Run the eval harness (stub).
6
+ validate-template — Validate a curriculum template against the W3.1 schema.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import argparse
12
+ import sys
13
+
14
+
15
+ def main(argv: list[str] | None = None) -> int:
16
+ parser = argparse.ArgumentParser(prog="mentar")
17
+ sub = parser.add_subparsers(dest="cmd", required=True)
18
+ sub.add_parser("serve", help="Start a pilot tutoring session (stub).")
19
+ sub.add_parser("eval", help="Run the eval harness (stub).")
20
+ vt = sub.add_parser(
21
+ "validate-template",
22
+ help="Validate a curriculum template against the W3.1 schema.",
23
+ )
24
+ vt.add_argument("path", help="Path to curriculum template Markdown file.")
25
+
26
+ args = parser.parse_args(argv)
27
+
28
+ if args.cmd == "validate-template":
29
+ from mentar.tools.validate_template import validate
30
+
31
+ result = validate(args.path)
32
+
33
+ for w in result.warnings:
34
+ print(f"WARNING: {w}", file=sys.stderr)
35
+
36
+ for e in result.errors:
37
+ print(f"ERROR: {e}", file=sys.stderr)
38
+
39
+ if result.ok:
40
+ n = len(result.concept_ids)
41
+ print(
42
+ f"OK: {args.path} — {n} concept(s); "
43
+ f"roots={result.roots}; leaves={result.leaves}",
44
+ file=sys.stdout,
45
+ )
46
+ if result.warnings:
47
+ print(f" {len(result.warnings)} warning(s) — see stderr.", file=sys.stdout)
48
+ else:
49
+ print(
50
+ f"FAIL: {args.path} — {len(result.errors)} error(s).",
51
+ file=sys.stdout,
52
+ )
53
+
54
+ return 0 if result.ok else 1
55
+
56
+ # stubs
57
+ print(f"mentar: '{args.cmd}' not implemented yet (stub).", file=sys.stderr)
58
+ return 1
59
+
60
+
61
+ if __name__ == "__main__":
62
+ raise SystemExit(main())
mentar/db/__init__.py ADDED
@@ -0,0 +1,4 @@
1
+ """Local SQLite store: learner profile, BKT state, response log, Help/probe/escalation events, transcripts.
2
+
3
+ Spec: docs/PHASE0.md W3.6; tests: T3.6.
4
+ """
mentar/db/store.py ADDED
@@ -0,0 +1,416 @@
1
+ """LearnerStore — minimal SQLite wrapper for Mentar learner data.
2
+
3
+ Spec: docs/PHASE0.md W3.6
4
+ Safety: docs/SAFETY.md Layer 4 (data/privacy), Layer 5 (parental oversight)
5
+ Tests: tests/db/test_datamodel.py (T3.6)
6
+
7
+ Design notes:
8
+ - Stdlib sqlite3 only; no ORM.
9
+ - Row factory = sqlite3.Row (dict-like access by column name).
10
+ - All queries are parameterised; no string interpolation.
11
+ - Schema applied from schema.sql on first open (user_version == 0).
12
+ - user_version == 1 after schema applied; future migrations bump this.
13
+ - Transcript immutability is enforced by DB triggers; this layer does not
14
+ add a second guard — the trigger is the authority.
15
+ - Multi-learner namespacing: every write method accepts learner_id and
16
+ every read method filters by learner_id. Never query without it.
17
+ - export/backup = file copy at OS level; call close() first so WAL is
18
+ checkpointed (see export note below).
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import sqlite3
24
+ from pathlib import Path
25
+ from typing import Union
26
+
27
+ # Path to the schema DDL file alongside this module.
28
+ _SCHEMA_PATH = Path(__file__).parent / "schema.sql"
29
+
30
+ _EXPECTED_VERSION = 1
31
+
32
+
33
+ class LearnerStore:
34
+ """Local SQLite store for one Mentar installation (one .db file per device).
35
+
36
+ Multi-learner support is achieved via the learner_id column present on
37
+ every table — each method scopes queries to a single learner.
38
+
39
+ Thread safety: sqlite3 connections are NOT thread-safe by default.
40
+ For the pilot (single-process, single-thread) this is fine. A future
41
+ multi-threaded web tier would need one connection per thread or a pool.
42
+ """
43
+
44
+ def __init__(self, db_path: Union[str, Path]) -> None:
45
+ """Open (or create) the SQLite database at *db_path*.
46
+
47
+ If the database is new (user_version == 0), the schema DDL in
48
+ schema.sql is applied and user_version is set to 1.
49
+ """
50
+ self._path = Path(db_path)
51
+ self._conn = sqlite3.connect(str(self._path))
52
+ self._conn.row_factory = sqlite3.Row
53
+ # Enable FK enforcement for this connection (must be per-connection).
54
+ self._conn.execute("PRAGMA foreign_keys = ON;")
55
+ self._apply_schema_if_needed()
56
+
57
+ # ── Schema management ────────────────────────────────────────────────────
58
+
59
+ def _apply_schema_if_needed(self) -> None:
60
+ """Apply schema.sql if the DB is uninitialised (user_version == 0)."""
61
+ version = self._user_version()
62
+ if version == 0:
63
+ ddl = _SCHEMA_PATH.read_text(encoding="utf-8")
64
+ self._conn.executescript(ddl)
65
+ self._conn.commit()
66
+ elif version < _EXPECTED_VERSION:
67
+ # Future migration hook: run incremental ALTER TABLE statements here,
68
+ # then bump user_version to _EXPECTED_VERSION.
69
+ # For now (schema v1 is the only version) this branch is unreachable.
70
+ raise RuntimeError(
71
+ f"Database schema version {version} is older than expected "
72
+ f"{_EXPECTED_VERSION}. Run the migration script."
73
+ )
74
+ # version == _EXPECTED_VERSION: nothing to do.
75
+
76
+ def _user_version(self) -> int:
77
+ row = self._conn.execute("PRAGMA user_version;").fetchone()
78
+ return int(row[0])
79
+
80
+ def schema_version(self) -> int:
81
+ """Return the current PRAGMA user_version of the database."""
82
+ return self._user_version()
83
+
84
+ # ── Learner profile ──────────────────────────────────────────────────────
85
+
86
+ def create_learner(
87
+ self,
88
+ name: str,
89
+ year_level: str,
90
+ country: str,
91
+ age_mode: str,
92
+ ) -> int:
93
+ """Insert a learner profile row and return the new learner_id (int)."""
94
+ cur = self._conn.execute(
95
+ """
96
+ INSERT INTO learner_profile (name, year_level, country, age_mode)
97
+ VALUES (?, ?, ?, ?)
98
+ """,
99
+ (name, year_level, country, age_mode),
100
+ )
101
+ self._conn.commit()
102
+ return cur.lastrowid # type: ignore[return-value]
103
+
104
+ def get_learner(self, learner_id: int) -> sqlite3.Row | None:
105
+ """Return the learner_profile row for *learner_id*, or None."""
106
+ return self._conn.execute(
107
+ "SELECT * FROM learner_profile WHERE id = ?;",
108
+ (learner_id,),
109
+ ).fetchone()
110
+
111
+ # ── Session ──────────────────────────────────────────────────────────────
112
+
113
+ def create_session(self, learner_id: int, session_id: str) -> None:
114
+ """Insert a session row. session_id is caller-supplied (e.g. UUID)."""
115
+ self._conn.execute(
116
+ "INSERT INTO session (id, learner_id) VALUES (?, ?);",
117
+ (session_id, learner_id),
118
+ )
119
+ self._conn.commit()
120
+
121
+ def end_session(self, learner_id: int, session_id: str, ended_reason: str) -> None:
122
+ """Mark a session as ended."""
123
+ self._conn.execute(
124
+ """
125
+ UPDATE session
126
+ SET ended_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now'),
127
+ ended_reason = ?
128
+ WHERE id = ? AND learner_id = ?;
129
+ """,
130
+ (ended_reason, session_id, learner_id),
131
+ )
132
+ self._conn.commit()
133
+
134
+ def get_session(self, learner_id: int, session_id: str) -> sqlite3.Row | None:
135
+ """Return a session row, scoped to the given learner."""
136
+ return self._conn.execute(
137
+ "SELECT * FROM session WHERE id = ? AND learner_id = ?;",
138
+ (session_id, learner_id),
139
+ ).fetchone()
140
+
141
+ # ── Skill state ──────────────────────────────────────────────────────────
142
+
143
+ def update_skill_state(
144
+ self,
145
+ learner_id: int,
146
+ skill_id: str,
147
+ p_mastery: float,
148
+ priors_used: bool,
149
+ ) -> None:
150
+ """Upsert the BKT mastery estimate for one skill.
151
+
152
+ Only p_mastery and prior_mode are updated here because the BKT
153
+ parameters (p_guess, p_slip, p_learns, p_forgets) are set once at
154
+ cold-start from the priors table and not changed until the fitted
155
+ model supersedes them (W3.3: N >= 100 scored responses per skill).
156
+ """
157
+ self._conn.execute(
158
+ """
159
+ INSERT INTO skill_state (learner_id, skill_id, p_mastery, prior_mode,
160
+ updated_at)
161
+ VALUES (?, ?, ?, ?,
162
+ strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))
163
+ ON CONFLICT (learner_id, skill_id) DO UPDATE
164
+ SET p_mastery = excluded.p_mastery,
165
+ prior_mode = excluded.prior_mode,
166
+ updated_at = excluded.updated_at;
167
+ """,
168
+ (learner_id, skill_id, p_mastery, int(priors_used)),
169
+ )
170
+ self._conn.commit()
171
+
172
+ def get_skill_state(self, learner_id: int, skill_id: str) -> sqlite3.Row | None:
173
+ """Return the skill_state row for one (learner, skill) pair."""
174
+ return self._conn.execute(
175
+ "SELECT * FROM skill_state WHERE learner_id = ? AND skill_id = ?;",
176
+ (learner_id, skill_id),
177
+ ).fetchone()
178
+
179
+ def all_skill_states(self, learner_id: int) -> list[sqlite3.Row]:
180
+ """Return all skill_state rows for a learner."""
181
+ return self._conn.execute(
182
+ "SELECT * FROM skill_state WHERE learner_id = ? ORDER BY skill_id;",
183
+ (learner_id,),
184
+ ).fetchall()
185
+
186
+ # ── Response log ─────────────────────────────────────────────────────────
187
+
188
+ def write_response(
189
+ self,
190
+ learner_id: int,
191
+ session_id: str,
192
+ skill_id: str,
193
+ prompt_ref: str,
194
+ answer: str,
195
+ scored: int,
196
+ hinted: int,
197
+ check_result: str | None = None,
198
+ ) -> int:
199
+ """Insert a response_log row and return the new response id."""
200
+ cur = self._conn.execute(
201
+ """
202
+ INSERT INTO response_log
203
+ (learner_id, session_id, skill_id, prompt_ref, answer,
204
+ scored, hinted, check_result)
205
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?);
206
+ """,
207
+ (learner_id, session_id, skill_id, prompt_ref, answer,
208
+ scored, hinted, check_result),
209
+ )
210
+ self._conn.commit()
211
+ return cur.lastrowid # type: ignore[return-value]
212
+
213
+ def session_responses(self, learner_id: int, session_id: str) -> list[dict]:
214
+ """Return all response_log rows for one (learner, session) pair as dicts."""
215
+ rows = self._conn.execute(
216
+ """
217
+ SELECT * FROM response_log
218
+ WHERE learner_id = ? AND session_id = ?
219
+ ORDER BY id;
220
+ """,
221
+ (learner_id, session_id),
222
+ ).fetchall()
223
+ return [dict(r) for r in rows]
224
+
225
+ # ── Help events ──────────────────────────────────────────────────────────
226
+
227
+ def write_help_event(
228
+ self,
229
+ learner_id: int,
230
+ session_id: str,
231
+ skill_id: str,
232
+ modality: str,
233
+ response_log_id: int,
234
+ ) -> int:
235
+ """Insert a help_event row and return the new id."""
236
+ cur = self._conn.execute(
237
+ """
238
+ INSERT INTO help_event
239
+ (learner_id, session_id, skill_id, modality, response_log_id)
240
+ VALUES (?, ?, ?, ?, ?);
241
+ """,
242
+ (learner_id, session_id, skill_id, modality, response_log_id),
243
+ )
244
+ self._conn.commit()
245
+ return cur.lastrowid # type: ignore[return-value]
246
+
247
+ def session_help_events(self, learner_id: int, session_id: str) -> list[dict]:
248
+ """Return all help_event rows for one (learner, session) pair as dicts."""
249
+ rows = self._conn.execute(
250
+ """
251
+ SELECT * FROM help_event
252
+ WHERE learner_id = ? AND session_id = ?
253
+ ORDER BY id;
254
+ """,
255
+ (learner_id, session_id),
256
+ ).fetchall()
257
+ return [dict(r) for r in rows]
258
+
259
+ # ── Probe events ─────────────────────────────────────────────────────────
260
+
261
+ def write_probe_event(
262
+ self,
263
+ learner_id: int,
264
+ session_id: str,
265
+ skill_id: str,
266
+ response_log_id: int,
267
+ retry_response_log_id: int | None,
268
+ class_: str,
269
+ ) -> int:
270
+ """Insert a probe_event row and return the new id."""
271
+ cur = self._conn.execute(
272
+ """
273
+ INSERT INTO probe_event
274
+ (learner_id, session_id, skill_id, response_log_id,
275
+ retry_response_log_id, class)
276
+ VALUES (?, ?, ?, ?, ?, ?);
277
+ """,
278
+ (learner_id, session_id, skill_id, response_log_id,
279
+ retry_response_log_id, class_),
280
+ )
281
+ self._conn.commit()
282
+ return cur.lastrowid # type: ignore[return-value]
283
+
284
+ def session_probe_events(self, learner_id: int, session_id: str) -> list[dict]:
285
+ """Return all probe_event rows for one (learner, session) pair as dicts."""
286
+ rows = self._conn.execute(
287
+ """
288
+ SELECT * FROM probe_event
289
+ WHERE learner_id = ? AND session_id = ?
290
+ ORDER BY id;
291
+ """,
292
+ (learner_id, session_id),
293
+ ).fetchall()
294
+ return [dict(r) for r in rows]
295
+
296
+ # ── Escalation log ───────────────────────────────────────────────────────
297
+
298
+ def write_escalation(
299
+ self,
300
+ learner_id: int,
301
+ trigger_class: str,
302
+ trigger_text_verbatim: str,
303
+ ) -> int:
304
+ """Insert an escalation_log row and return the new id.
305
+
306
+ trigger_text_verbatim is stored exactly as received — never truncated
307
+ (SAFETY.md §3.3 Step 2: "never silently dropped").
308
+ """
309
+ cur = self._conn.execute(
310
+ """
311
+ INSERT INTO escalation_log
312
+ (learner_id, trigger_class, trigger_text_verbatim)
313
+ VALUES (?, ?, ?);
314
+ """,
315
+ (learner_id, trigger_class, trigger_text_verbatim),
316
+ )
317
+ self._conn.commit()
318
+ return cur.lastrowid # type: ignore[return-value]
319
+
320
+ def parent_ack_escalation(self, esc_id: int) -> None:
321
+ """Record the parent's acknowledgment of an escalation event."""
322
+ self._conn.execute(
323
+ """
324
+ UPDATE escalation_log
325
+ SET parent_ack_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now'),
326
+ session_outcome = 'acknowledged'
327
+ WHERE id = ?;
328
+ """,
329
+ (esc_id,),
330
+ )
331
+ self._conn.commit()
332
+
333
+ def get_escalation(self, learner_id: int, esc_id: int) -> sqlite3.Row | None:
334
+ """Return one escalation_log row, scoped to learner."""
335
+ return self._conn.execute(
336
+ "SELECT * FROM escalation_log WHERE id = ? AND learner_id = ?;",
337
+ (esc_id, learner_id),
338
+ ).fetchone()
339
+
340
+ def learner_escalations(self, learner_id: int) -> list[dict]:
341
+ """Return all escalation_log rows for a learner as dicts."""
342
+ rows = self._conn.execute(
343
+ "SELECT * FROM escalation_log WHERE learner_id = ? ORDER BY id;",
344
+ (learner_id,),
345
+ ).fetchall()
346
+ return [dict(r) for r in rows]
347
+
348
+ # ── Transcript ───────────────────────────────────────────────────────────
349
+
350
+ def write_transcript(
351
+ self,
352
+ learner_id: int,
353
+ session_id: str,
354
+ turn_index: int,
355
+ role: str,
356
+ text: str,
357
+ ) -> int:
358
+ """Append one turn to the immutable transcript and return the new id.
359
+
360
+ Immutability is enforced by DB triggers (trg_transcript_no_update and
361
+ trg_transcript_no_delete in schema.sql) — attempts to UPDATE or DELETE
362
+ a transcript row raise sqlite3.OperationalError.
363
+ """
364
+ cur = self._conn.execute(
365
+ """
366
+ INSERT INTO transcript
367
+ (learner_id, session_id, turn_index, role, text)
368
+ VALUES (?, ?, ?, ?, ?);
369
+ """,
370
+ (learner_id, session_id, turn_index, role, text),
371
+ )
372
+ self._conn.commit()
373
+ return cur.lastrowid # type: ignore[return-value]
374
+
375
+ def transcript_for_session(
376
+ self, learner_id: int, session_id: str
377
+ ) -> list[dict]:
378
+ """Return all transcript rows for one (learner, session) pair as dicts.
379
+
380
+ Ordered by turn_index ascending — safe for deterministic replay.
381
+ """
382
+ rows = self._conn.execute(
383
+ """
384
+ SELECT * FROM transcript
385
+ WHERE learner_id = ? AND session_id = ?
386
+ ORDER BY turn_index ASC;
387
+ """,
388
+ (learner_id, session_id),
389
+ ).fetchall()
390
+ return [dict(r) for r in rows]
391
+
392
+ # ── Connection lifecycle ─────────────────────────────────────────────────
393
+
394
+ def checkpoint(self) -> None:
395
+ """Checkpoint the WAL so an OS file-copy produces a consistent snapshot.
396
+
397
+ Call this before shutil.copy2() / any file-level export.
398
+ PRAGMA wal_checkpoint(TRUNCATE) flushes and truncates the WAL file.
399
+ """
400
+ self._conn.execute("PRAGMA wal_checkpoint(TRUNCATE);")
401
+
402
+ def close(self) -> None:
403
+ """Checkpoint and close the connection.
404
+
405
+ After close() the .db file is safe to copy (export = file copy per W3.6).
406
+ """
407
+ self.checkpoint()
408
+ self._conn.close()
409
+
410
+ # ── Context manager support ──────────────────────────────────────────────
411
+
412
+ def __enter__(self) -> "LearnerStore":
413
+ return self
414
+
415
+ def __exit__(self, *_: object) -> None:
416
+ self.close()
@@ -0,0 +1,4 @@
1
+ """Session controller, FSM, Help loop, probe trigger, prompt assembly.
2
+
3
+ Spec: docs/SPEC.md §12-14; FSM: docs/SESSION_FSM.md (W6.1); tests: T3.7, T4.x, T5.x.
4
+ """
@@ -0,0 +1,4 @@
1
+ """KST concept graph, BKT mastery, fringe selection, deterministic verifiers.
2
+
3
+ Spec: docs/SPEC.md §10-11; tests: T3.2 (fringe), T3.3 (BKT), T3.5 (verifier).
4
+ """
mentar/engine/bkt.py ADDED
@@ -0,0 +1,99 @@
1
+ """BKT per-turn mastery update — cold-start priors + hinted-win discount.
2
+
3
+ Spec: docs/SPEC.md §11 (Mastery / BKT), §13.2 (hinted-win); docs/design/W3.3_bkt.md.
4
+ Tests: docs/TESTS.md T3.3.
5
+
6
+ This is Mentar's own deterministic BKT recurrence (Corbett & Anderson 1995),
7
+ used for the per-turn update in the session FSM `bkt_update` state. pyBKT is NOT
8
+ called here: it cannot fit parameters from one learner's cold-start (W3.3), so it
9
+ is reserved for OFFLINE parameter fitting post-pilot (N >= 100 scored responses
10
+ per skill, flipping skill_state.prior_mode -> 0). See design doc §1.
11
+
12
+ stdlib-only, pure, side-effect-free: the caller persists the result via
13
+ store.update_skill_state(). No DB, no I/O, no RNG.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ from dataclasses import dataclass
19
+
20
+ # Initial mastery prior for a freshly-fringed concept (design §2). NOT 0.0:
21
+ # at exactly 0, no correct answer can ever move mastery (0*(1-slip)/... == 0).
22
+ P_L0 = 0.10
23
+
24
+ # How much a hinted-correct answer is discounted, as a fraction of the gap to a
25
+ # pure guess. 0.5 => guess_hinted is a coin-flip's worth of evidence (design §3.1).
26
+ # Deliberately strong: over-crediting a hinted win is the dangerous direction.
27
+ HINT_DISCOUNT = 0.5
28
+
29
+ # Node-class defaults keyed by verifier.answer_type (design §2). Template
30
+ # `bkt_priors:` overrides these per node.
31
+ _CLASS_DEFAULTS = {
32
+ "mc4": {"guess": 0.20, "slip": 0.10, "learns": 0.20, "forgets": 0.0},
33
+ "numeric": {"guess": 0.05, "slip": 0.10, "learns": 0.20, "forgets": 0.0},
34
+ }
35
+ # answer_type -> node class
36
+ _NUMERIC_TYPES = frozenset({"int", "decimal", "fraction"})
37
+
38
+
39
+ @dataclass(frozen=True)
40
+ class BktParams:
41
+ """Per-skill BKT parameters. forgets is stored for forward-compat; unused in v0."""
42
+
43
+ guess: float
44
+ slip: float
45
+ learns: float
46
+ forgets: float = 0.0
47
+
48
+
49
+ def params_for(answer_type: str, overrides: dict | None = None) -> BktParams:
50
+ """Resolve params for a node: template `bkt_priors:` override wins, else the
51
+ class default by answer_type (design §2). `mc4` -> MC default; int/decimal/
52
+ fraction -> numeric default."""
53
+ node_class = "mc4" if answer_type == "mc4" else (
54
+ "numeric" if answer_type in _NUMERIC_TYPES else None
55
+ )
56
+ if node_class is None:
57
+ raise ValueError(f"no BKT prior class for answer_type {answer_type!r}")
58
+ base = dict(_CLASS_DEFAULTS[node_class])
59
+ if overrides:
60
+ base.update({k: v for k, v in overrides.items() if k in base})
61
+ return BktParams(**base)
62
+
63
+
64
+ def _posterior_given_obs(p: float, correct: bool, guess: float, slip: float) -> float:
65
+ """Bayesian conditioning of mastery on one observation (design §3 step a)."""
66
+ if correct:
67
+ num = p * (1.0 - slip)
68
+ den = num + (1.0 - p) * guess
69
+ else:
70
+ num = p * slip
71
+ den = num + (1.0 - p) * (1.0 - guess)
72
+ return num / den if den > 0.0 else p
73
+
74
+
75
+ def bkt_update(
76
+ p_prior: float | None,
77
+ correct: bool,
78
+ hinted: bool,
79
+ params: BktParams,
80
+ ) -> float:
81
+ """Return the updated p_mastery after one scored observation.
82
+
83
+ p_prior is the current skill_state.p_mastery; pass None (or 0.0) for an
84
+ uninitialised skill -> seeded to P_L0 (design §2; regression guard for the
85
+ degenerate-zero bug).
86
+
87
+ Hinted-win discount (design §3.1): a *correct* answer after Help uses an
88
+ elevated guess, so it raises mastery strictly less than a cold correct. A
89
+ hinted *incorrect* uses the normal guess (we do not soften a wrong answer).
90
+ """
91
+ p = P_L0 if (p_prior is None or p_prior <= 0.0) else p_prior
92
+
93
+ guess_eff = params.guess
94
+ if correct and hinted:
95
+ guess_eff = params.guess + (1.0 - params.guess) * HINT_DISCOUNT
96
+
97
+ p_cond = _posterior_given_obs(p, correct, guess_eff, params.slip)
98
+ # learning transition (within-session; forgets unused in v0)
99
+ return p_cond + (1.0 - p_cond) * params.learns