masterytrace-cli 0.1.0__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.
@@ -0,0 +1,52 @@
1
+ """
2
+ Converts the library's dataclass return values (ResponseEvent,
3
+ MasteryReport, EngineResult, BktParams, ...) into plain JSON-serializable
4
+ dicts for `.masterytrace/*.json` state files and `--json` CLI output.
5
+
6
+ Two different conventions are used deliberately, matching each value's
7
+ role:
8
+
9
+ - `ResponseEvent` (the event-log wire format read by `parse_response_events`
10
+ and produced by `masterytrace init`/`record`) always serializes to
11
+ **camelCase** (`learnerId`, `skillId`, ...): this is the shared,
12
+ cross-distribution event-log contract with the npm CLI, and
13
+ `parse_response_events` itself only recognizes these camelCase keys, so
14
+ an event log this CLI writes must stay readable by this CLI (and by the
15
+ npm CLI) without a key-casing mismatch.
16
+ - Everything else (scores, reports, engine results) serializes to
17
+ **snake_case**, matching Python naming convention -- see
18
+ python/README.md's "deliberate naming divergence" note. These values
19
+ are never fed back through `parse_response_events`, so there is no
20
+ cross-format contract to preserve.
21
+ """
22
+ from __future__ import annotations
23
+
24
+ import dataclasses
25
+ from typing import Any, List
26
+
27
+ from ..core.event_schema import ResponseEvent
28
+
29
+
30
+ def response_event_to_dict(event: ResponseEvent) -> dict:
31
+ return {
32
+ "learnerId": event.learner_id,
33
+ "skillId": event.skill_id,
34
+ "correct": event.correct,
35
+ "timestamp": event.timestamp,
36
+ }
37
+
38
+
39
+ def response_events_to_jsonable(events: List[ResponseEvent]) -> List[dict]:
40
+ return [response_event_to_dict(e) for e in events]
41
+
42
+
43
+ def to_jsonable(value: Any) -> Any:
44
+ if isinstance(value, ResponseEvent):
45
+ return response_event_to_dict(value)
46
+ if dataclasses.is_dataclass(value) and not isinstance(value, type):
47
+ return {k: to_jsonable(v) for k, v in dataclasses.asdict(value).items()}
48
+ if isinstance(value, dict):
49
+ return {k: to_jsonable(v) for k, v in value.items()}
50
+ if isinstance(value, (list, tuple)):
51
+ return [to_jsonable(v) for v in value]
52
+ return value
@@ -0,0 +1,21 @@
1
+ """
2
+ Every CLI command handler returns this shape instead of writing to
3
+ stdout/exiting directly, so integration tests can invoke handlers as plain
4
+ functions and assert on their output/exit code without spawning a
5
+ subprocess. Ported from src/cli/types.ts.
6
+
7
+ Exit code contract (required so an agent invoking this CLI programmatically
8
+ can rely on it): 0 = success, 1 = general/usage error, 2 = validation
9
+ error (bad event data).
10
+ """
11
+ from __future__ import annotations
12
+
13
+ from dataclasses import dataclass
14
+ from typing import Optional
15
+
16
+
17
+ @dataclass
18
+ class CommandResult:
19
+ exit_code: int
20
+ stdout: str
21
+ stderr: Optional[str] = None
File without changes
@@ -0,0 +1,62 @@
1
+ """
2
+ Project-level configuration read from `masterytrace.config.json` in the
3
+ current directory (scaffolded by `masterytrace init`). Ported from
4
+ src/core/config.ts.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import json
9
+ from dataclasses import dataclass
10
+ from pathlib import Path
11
+ from typing import Any, Dict, Optional
12
+
13
+ from ..models.bkt import BktConfig
14
+ from ..models.irt import IrtConfig
15
+
16
+ CONFIG_FILENAME = "masterytrace.config.json"
17
+
18
+
19
+ @dataclass
20
+ class MasteryTraceConfig:
21
+ bkt: Optional[Dict[str, Any]] = None
22
+ irt: Optional[Dict[str, Any]] = None
23
+
24
+
25
+ DEFAULT_CONFIG: Dict[str, Any] = {"bkt": {"fit": False}, "irt": {}}
26
+
27
+
28
+ def load_config(cwd: str) -> Dict[str, Any]:
29
+ """
30
+ Loads `masterytrace.config.json` from `cwd` if present, merging it
31
+ over DEFAULT_CONFIG; otherwise returns DEFAULT_CONFIG unchanged.
32
+ """
33
+ config_path = Path(cwd) / CONFIG_FILENAME
34
+ if not config_path.exists():
35
+ return dict(DEFAULT_CONFIG)
36
+ raw = json.loads(config_path.read_text(encoding="utf-8"))
37
+ merged = dict(DEFAULT_CONFIG)
38
+ merged.update(raw)
39
+ return merged
40
+
41
+
42
+ def bkt_config_from_dict(raw: Optional[Dict[str, Any]]) -> BktConfig:
43
+ """Builds a BktConfig from the plain dict shape stored in masterytrace.config.json."""
44
+ raw = raw or {}
45
+ return BktConfig(
46
+ default_params=raw.get("defaultParams"),
47
+ skill_params=raw.get("skillParams"),
48
+ fit=bool(raw.get("fit", False)),
49
+ )
50
+
51
+
52
+ def irt_config_from_dict(raw: Optional[Dict[str, Any]]) -> IrtConfig:
53
+ """Builds an IrtConfig from the plain dict shape stored in masterytrace.config.json."""
54
+ raw = raw or {}
55
+ kwargs: Dict[str, Any] = {}
56
+ if "iterations" in raw:
57
+ kwargs["iterations"] = raw["iterations"]
58
+ if "learningRate" in raw:
59
+ kwargs["learning_rate"] = raw["learningRate"]
60
+ if "regularization" in raw:
61
+ kwargs["regularization"] = raw["regularization"]
62
+ return IrtConfig(**kwargs)
@@ -0,0 +1,54 @@
1
+ """
2
+ Orchestrates the scoring pipeline: given a validated event log, runs
3
+ whichever model(s) were requested and returns their reports together.
4
+ Ported from src/core/engine.ts.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ from dataclasses import dataclass, field
9
+ from datetime import datetime, timezone
10
+ from typing import List, Literal, Optional
11
+
12
+ from ..models.bkt import BktConfig, BktModel
13
+ from ..models.irt import IrtConfig, IrtModel
14
+ from .event_schema import ResponseEvent
15
+ from .scoring_model import MasteryReport
16
+
17
+ ModelSelector = Literal["bkt", "irt", "both"]
18
+
19
+
20
+ @dataclass
21
+ class EngineConfig:
22
+ bkt: Optional[BktConfig] = None
23
+ irt: Optional[IrtConfig] = None
24
+
25
+
26
+ @dataclass
27
+ class EngineResult:
28
+ generated_at: str
29
+ reports: List[MasteryReport] = field(default_factory=list)
30
+
31
+
32
+ def run_scoring(
33
+ events: List[ResponseEvent],
34
+ selector: ModelSelector = "both",
35
+ config: Optional[EngineConfig] = None,
36
+ ) -> EngineResult:
37
+ """
38
+ Runs whichever model(s) were requested and returns their reports
39
+ together. This is the single place that knows how to wire the engine
40
+ config into concrete model instances; callers (CLI or library
41
+ consumers) only pick a model selector and pass raw events.
42
+ """
43
+ config = config or EngineConfig()
44
+ reports: List[MasteryReport] = []
45
+
46
+ if selector in ("bkt", "both"):
47
+ model = BktModel(config.bkt)
48
+ reports.append(model.score(model.fit(events)))
49
+
50
+ if selector in ("irt", "both"):
51
+ model = IrtModel(config.irt)
52
+ reports.append(model.score(model.fit(events)))
53
+
54
+ return EngineResult(generated_at=datetime.now(timezone.utc).isoformat().replace("+00:00", "Z"), reports=reports)
@@ -0,0 +1,131 @@
1
+ """
2
+ A single learner response event: one attempt by one learner at one skill,
3
+ scored correct/incorrect, at a point in time.
4
+
5
+ This is the only unit of data every scoring model and adapter in
6
+ MasteryTrace operates on. Ported from src/core/event-schema.ts, which uses
7
+ `zod` for validation; this port hand-rolls the equivalent field checks
8
+ (learnerId/skillId non-empty strings, correct a bool, timestamp a parseable
9
+ ISO 8601 string) to avoid a validation-library dependency the TypeScript
10
+ side has but the Python side does not need for four fields.
11
+ """
12
+ from __future__ import annotations
13
+
14
+ from dataclasses import dataclass
15
+ from datetime import datetime
16
+ from typing import Any, List
17
+
18
+
19
+ @dataclass(frozen=True)
20
+ class ResponseEvent:
21
+ learner_id: str
22
+ skill_id: str
23
+ correct: bool
24
+ timestamp: str
25
+
26
+
27
+ class EventValidationError(Exception):
28
+ """
29
+ Raised when an event log fails validation. `issues` lists every
30
+ field/row failure found, so a caller (or the CLI) can report all
31
+ problems at once instead of stopping at the first one -- matching the
32
+ TypeScript EventValidationError contract exactly (same field name,
33
+ same "row N: field 'x' - message" issue format).
34
+ """
35
+
36
+ def __init__(self, message: str, issues: List[str]) -> None:
37
+ super().__init__(message)
38
+ self.issues = issues
39
+
40
+
41
+ def _is_valid_iso8601(value: str) -> bool:
42
+ """
43
+ Mirrors `!Number.isNaN(Date.parse(value))` closely enough for the
44
+ timestamps this project actually produces and accepts (ISO 8601,
45
+ optionally with a trailing 'Z'). datetime.fromisoformat() is stricter
46
+ than JS's Date.parse() about some non-standard formats, but every
47
+ timestamp MasteryTrace itself generates (sample data, CLI-recorded
48
+ logs) is a real ISO 8601 string, so this does not reject valid
49
+ MasteryTrace data.
50
+ """
51
+ if not isinstance(value, str) or value == "":
52
+ return False
53
+ candidate = value[:-1] + "+00:00" if value.endswith("Z") else value
54
+ try:
55
+ datetime.fromisoformat(candidate)
56
+ return True
57
+ except ValueError:
58
+ return False
59
+
60
+
61
+ def _validate_row(row: Any, index: int, issues: List[str]) -> ResponseEvent | None:
62
+ if not isinstance(row, dict):
63
+ issues.append(f"row {index}: field '(row)' - expected an object")
64
+ return None
65
+
66
+ ok = True
67
+
68
+ learner_id = row.get("learnerId")
69
+ if not isinstance(learner_id, str):
70
+ issues.append(f"row {index}: field 'learnerId' - learnerId must be a string")
71
+ ok = False
72
+ elif len(learner_id) < 1:
73
+ issues.append(f"row {index}: field 'learnerId' - learnerId must be a non-empty string")
74
+ ok = False
75
+
76
+ skill_id = row.get("skillId")
77
+ if not isinstance(skill_id, str):
78
+ issues.append(f"row {index}: field 'skillId' - skillId must be a string")
79
+ ok = False
80
+ elif len(skill_id) < 1:
81
+ issues.append(f"row {index}: field 'skillId' - skillId must be a non-empty string")
82
+ ok = False
83
+
84
+ correct = row.get("correct")
85
+ if not isinstance(correct, bool):
86
+ issues.append(f"row {index}: field 'correct' - correct must be a boolean")
87
+ ok = False
88
+
89
+ timestamp = row.get("timestamp")
90
+ if not isinstance(timestamp, str):
91
+ issues.append(f"row {index}: field 'timestamp' - timestamp must be an ISO 8601 date string")
92
+ ok = False
93
+ elif not _is_valid_iso8601(timestamp):
94
+ issues.append(f"row {index}: field 'timestamp' - timestamp must be a valid ISO 8601 date string")
95
+ ok = False
96
+
97
+ if not ok:
98
+ return None
99
+ return ResponseEvent(learner_id=learner_id, skill_id=skill_id, correct=correct, timestamp=timestamp)
100
+
101
+
102
+ def parse_response_events(raw: Any) -> List[ResponseEvent]:
103
+ """
104
+ Parses an unknown value (typically json.load output or rows decoded
105
+ from CSV) into a validated list of ResponseEvent. Every row is
106
+ checked; on failure the error lists every failing row/field, not just
107
+ the first.
108
+ """
109
+ if not isinstance(raw, list):
110
+ raise EventValidationError(
111
+ "Event log must be a JSON array of response events (row 0: expected array, got "
112
+ f"{type(raw).__name__})",
113
+ ["row 0: expected an array of response events"],
114
+ )
115
+
116
+ events: List[ResponseEvent] = []
117
+ issues: List[str] = []
118
+
119
+ for index, row in enumerate(raw):
120
+ event = _validate_row(row, index, issues)
121
+ if event is not None:
122
+ events.append(event)
123
+
124
+ if issues:
125
+ plural = "" if len(issues) == 1 else "s"
126
+ raise EventValidationError(
127
+ f"Event log failed validation ({len(issues)} issue{plural}):\n" + "\n".join(issues),
128
+ issues,
129
+ )
130
+
131
+ return events
@@ -0,0 +1,68 @@
1
+ """
2
+ The unified report/model shapes both BKT and IRT produce, letting the
3
+ engine and CLI treat either psychometric model interchangeably. Ported
4
+ from src/core/scoring-model.ts.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ from dataclasses import dataclass, field
9
+ from typing import Any, Dict, List, Optional, Protocol, TypeVar
10
+
11
+
12
+ @dataclass
13
+ class MasterySkillEntry:
14
+ """
15
+ A single per-skill mastery estimate for one learner, as produced by a
16
+ scoring model. `metric` names what `value` means so a report consumer
17
+ (or the CLI's table renderer) can label it correctly without knowing
18
+ which model produced it: 'posterior_mastery_probability' (BKT) or
19
+ 'ability_theta' (IRT).
20
+ """
21
+
22
+ skill_id: str
23
+ metric: str
24
+ value: float
25
+ response_count: int
26
+ details: Optional[Dict[str, Any]] = None
27
+
28
+
29
+ @dataclass
30
+ class MasteryLearnerEntry:
31
+ learner_id: str
32
+ skills: List[MasterySkillEntry] = field(default_factory=list)
33
+
34
+
35
+ @dataclass
36
+ class MasteryReport:
37
+ """
38
+ The unified shape every ScoringModel.score() returns, regardless of
39
+ which psychometric model produced it.
40
+ """
41
+
42
+ model: str
43
+ generated_at: str
44
+ learners: List[MasteryLearnerEntry] = field(default_factory=list)
45
+ meta: Optional[Dict[str, Any]] = None
46
+
47
+
48
+ @dataclass
49
+ class FittedModel:
50
+ """Opaque fitted-model artifact produced by ScoringModel.fit()."""
51
+
52
+ model_name: str
53
+
54
+
55
+ F = TypeVar("F", bound=FittedModel)
56
+
57
+
58
+ class ScoringModel(Protocol[F]):
59
+ """
60
+ Common interface both BKT and IRT implement, so an engine or CLI can
61
+ fit and score either model (or both) through the same code path.
62
+ """
63
+
64
+ name: str
65
+
66
+ def fit(self, events: List[Any]) -> F: ...
67
+
68
+ def score(self, fitted_model: F) -> MasteryReport: ...
File without changes
@@ -0,0 +1,78 @@
1
+ """
2
+ Bundled example event log for `masterytrace init`: 3 learners x 3 skills,
3
+ 6-7 responses each, with a deterministic (seeded) but plausible "improving
4
+ over time" pattern per learner. Ported from src/data/sample-events.ts,
5
+ including its linear congruential generator, so the bundled sample data
6
+ follows the same generation algorithm as the npm package's (the two are
7
+ not required to be byte-identical, only algorithmically equivalent, since
8
+ each runtime's `Date`/timestamp formatting differs slightly).
9
+ """
10
+ from __future__ import annotations
11
+
12
+ from datetime import datetime, timedelta, timezone
13
+ from typing import Callable, Dict, List
14
+
15
+ from ..core.event_schema import ResponseEvent
16
+
17
+ _UINT32_MASK = 0xFFFFFFFF
18
+
19
+
20
+ def _make_lcg(seed: int) -> Callable[[], float]:
21
+ """
22
+ A small deterministic linear congruential generator, used only to keep
23
+ the bundled sample data reproducible (no random.random()) while still
24
+ looking like a real, slightly noisy response log rather than a
25
+ hand-typed pattern.
26
+ """
27
+ state = seed & _UINT32_MASK
28
+
29
+ def _next() -> float:
30
+ nonlocal state
31
+ state = (state * 1664525 + 1013904223) & _UINT32_MASK
32
+ return state / _UINT32_MASK
33
+
34
+ return _next
35
+
36
+
37
+ _LEARNERS = ["learner-ada", "learner-brook", "learner-cyrus"]
38
+ _SKILLS = ["fractions", "linear-equations", "reading-comprehension"]
39
+
40
+ # Roughly how likely each learner is to answer correctly on their Nth
41
+ # attempt at a skill (index 0 = first attempt), used only to shape the
42
+ # bundled sample into a plausible "learning over time" curve.
43
+ _LEARNING_CURVES: Dict[str, List[float]] = {
44
+ "learner-ada": [0.3, 0.4, 0.55, 0.7, 0.8, 0.85, 0.9],
45
+ "learner-brook": [0.2, 0.25, 0.3, 0.45, 0.55, 0.65, 0.75],
46
+ "learner-cyrus": [0.5, 0.65, 0.75, 0.85, 0.9, 0.92, 0.95],
47
+ }
48
+ _DEFAULT_CURVE = [0.4, 0.5, 0.6, 0.7, 0.8, 0.85, 0.9]
49
+
50
+
51
+ def _build_sample_events() -> List[ResponseEvent]:
52
+ rand = _make_lcg(42)
53
+ events: List[ResponseEvent] = []
54
+ start_date = datetime(2026, 1, 5, 9, 0, 0, tzinfo=timezone.utc)
55
+ day_offset = 0
56
+
57
+ for skill_id in _SKILLS:
58
+ for learner_id in _LEARNERS:
59
+ curve = _LEARNING_CURVES.get(learner_id, _DEFAULT_CURVE)
60
+ attempts = 6 + int(rand() * 2) # 6 or 7 attempts per learner+skill
61
+ for attempt in range(attempts):
62
+ p_correct = curve[min(attempt, len(curve) - 1)]
63
+ correct = rand() < p_correct
64
+ timestamp = start_date + timedelta(days=day_offset, hours=attempt)
65
+ events.append(
66
+ ResponseEvent(
67
+ learner_id=learner_id,
68
+ skill_id=skill_id,
69
+ correct=correct,
70
+ timestamp=timestamp.isoformat().replace("+00:00", "Z"),
71
+ )
72
+ )
73
+ day_offset += 1
74
+
75
+ return events
76
+
77
+
78
+ SAMPLE_EVENTS: List[ResponseEvent] = _build_sample_events()
File without changes