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.
- masterytrace/__init__.py +68 -0
- masterytrace/adapters/__init__.py +0 -0
- masterytrace/adapters/generic_adapter.py +134 -0
- masterytrace/cli/__init__.py +0 -0
- masterytrace/cli/commands/__init__.py +0 -0
- masterytrace/cli/commands/init.py +57 -0
- masterytrace/cli/commands/record.py +51 -0
- masterytrace/cli/commands/report.py +102 -0
- masterytrace/cli/commands/score.py +63 -0
- masterytrace/cli/format.py +17 -0
- masterytrace/cli/index.py +177 -0
- masterytrace/cli/json_encode.py +52 -0
- masterytrace/cli/types.py +21 -0
- masterytrace/core/__init__.py +0 -0
- masterytrace/core/config.py +62 -0
- masterytrace/core/engine.py +54 -0
- masterytrace/core/event_schema.py +131 -0
- masterytrace/core/scoring_model.py +68 -0
- masterytrace/data/__init__.py +0 -0
- masterytrace/data/sample_events.py +78 -0
- masterytrace/models/__init__.py +0 -0
- masterytrace/models/bkt.py +275 -0
- masterytrace/models/irt.py +285 -0
- masterytrace/py.typed +0 -0
- masterytrace_cli-0.1.0.dist-info/METADATA +362 -0
- masterytrace_cli-0.1.0.dist-info/RECORD +29 -0
- masterytrace_cli-0.1.0.dist-info/WHEEL +4 -0
- masterytrace_cli-0.1.0.dist-info/entry_points.txt +2 -0
- masterytrace_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -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
|