feat: BKT mastery estimate (M1) and noise-tolerant landmark detection (M2) - #25
Draft
Swissystem7 wants to merge 3 commits into
Draft
Swissystem7 wants to merge 3 commits into
Swissystem7 wants to merge 3 commits into
Conversation
…ng in adaptive.js
M1 from the MelodyMath escalation backlog.
mastery.js gains the standard two-stage BKT recurrence:
evidence correct: P(1-pSlip) / [P(1-pSlip) + (1-P)pGuess]
wrong: P*pSlip / [P*pSlip + (1-P)(1-pGuess)]
learning P_next = P(K|obs) + (1 - P(K|obs)) * pLearn
exported as bktPosterior (stage 1 alone), bktUpdate (both stages),
masteryFromHistory (fold a boolean or {correct} history) and masteryBand
(< 0.4 easier, 0.4..0.85 same, > 0.85 harder). Defaults are the backlog's:
pInit 0.2, pLearn 0.15, pSlip 0.1, pGuess 0.25.
adaptive.js gains nextLevelFromMastery and paceLevel; eligibleExercises takes
an optional 5th argument that selects the rule. The curriculum gate, nextLevel
and every other existing export are untouched.
TWO BACKLOG NUMBERS ARE WRONG AND THE TESTS PIN THE COMPUTED ONES INSTEAD.
1. The backlog pins "one correct answer from pInit 0.2 gives P(known) = 0.5407".
The recurrence gives 0.5526:
evidence 0.2*0.9 / (0.2*0.9 + 0.8*0.25) = 0.18/0.38 = 9/19 = 0.4736842
learning 9/19 + (10/19)*0.15 = 10.5/19 = 21/38 = 0.5526316
0.5407 is not the evidence stage alone (0.4737) and not learn-before-observe
(0.6288) either. The code keeps the standard recurrence; the test pins
0.5526 and records the discrepancy so it is not "fixed" back.
2. The backlog pins "ten wrong answers stay below 0.1". That is unreachable by
construction: the evidence stage is a probability so it is >= 0, hence
P_next = post + (1-post)*pLearn >= pLearn = 0.15 for every observation.
The wrong-answer fixed point is 9/52 = 0.1730769, and ten wrong answers from
pInit 0.2 give 0.1731. The test pins 0.1731, asserts the pLearn floor, and
shows that < 0.1 does become reachable with a smaller pLearn.
The other two acceptance criteria hold as written: P(known) is monotone under
correct answers, and ten consecutive correct answers give 1.0000 (> 0.95; it
clears 0.95 already on the third).
WHY BKT PACING IS OPT-IN. The backlog asks for BKT "instead of raw streaks"
AND for the existing tests to keep passing without rewriting. Those conflict:
two correct answers give P(known) = 0.843952, inside the 0.4..0.85 HOLD band,
while test/banks.test.js:153 pins level 2 after exactly those two answers
because that is what the streak rule does. Under BKT the level moves up on the
third correct answer (0.958476). So the default stays 'streak' and no existing
behaviour changes; pass { pace: 'bkt' } for the P(known) rule. Flipping the
default is one line here plus one expectation in banks.test.js, and that is a
pedagogical call for the owner.
Suite: 161 -> 177 passing, 0 failing.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…e-graph
M2 from the MelodyMath escalation backlog.
findRoots and findExtrema are literal readings of the sampling: every sign
change is a root, every three-sample wobble is an extremum. On the sine used by
the listen lessons plus a +/-0.01 jitter that is 22 "extrema" instead of 2.
findLandmarks(samples, { epsilon }) is the robust reading of the SAME samples
and changes nothing about the existing detectors, which stay exported and
still pass their own tests.
prominence an extremum is reported only after the curve retraces by more
than epsilon of the sampled y-range (default 0.05). Its x is the
(y - threshold)-weighted centroid of the plateau, not the raw
argmax: near a peak the curve is quadratic, so a few samples out
the drop is smaller than the noise and the argmax wanders by
several samples while the centroid does not.
merging crossings closer together than one sample step are one crossing.
localisation a sign change pins a zero only to about noise/slope. Where
several samples sit inside the same epsilon band around the
axis, a least-squares line through all of them is used instead,
which averages the noise down.
ENDPOINT POLICY, chosen and documented:
* the first and last samples are never extrema — a turning point needs a
retrace to confirm it and there is nothing past the edge to retrace into;
* the first and last samples DO count as roots when they sit on the axis,
within both the epsilon-of-range tolerance AND a sample or two of the axis
at the local slope. The slope half matters: 1/x on [-4, 4] spans 20 units,
so 0.25 is well inside epsilon of the range, and without it y(-4) = -0.25
would be announced as a zero crossing. It is not one.
So sin(x) on [0, 2*pi] with 200 samples gives 2 extrema (pi/2, 3*pi/2) and 3
roots (0, pi, 2*pi).
describeLandmarksHe lists them left to right, one decimal each, and keeps the
module's standing disclaimer that this describes the sampling on screen and is
not the voice of the function. Its optional second argument separates "flat"
from "no landmarks here": y = x + 1 on [0, 2] has no landmarks either, and
calling that curve flat would be a lie.
Tests are deterministic: no Math.random, no timers, no audio. The jitter is a
fixed 32-entry table of literals. Verified before pinning: with the jittered
sine the landmark list is identical to the clean one, kind for kind and to one
decimal, and produces the same Hebrew sentence. That was checked against 200
independently generated tables at 4 phase offsets each, 800/800 identical --
so the pinned table is representative, not a lucky pick. (Before the
least-squares root step, 14 of those 800 rendered the root at pi as 3.2 rather
than 3.1: pi is only 0.008 from the 3.15 rounding boundary, and a single
bracketing pair cannot localise a zero that well under 0.01 noise.)
No backlog number was wrong in M2: 2 extrema and 3 zero crossings is what the
correct algorithm produces, once the endpoint policy is stated.
Suite: 177 -> 187 passing, 0 failing.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
… edge bias, read text state Independent verification of this branch (2026-09-10) re-derived every pinned number and found two things the suite could not see: deleting refineCrossing or centroidOf left 187/187 green, although both measurably matter; and centroidOf clipped its plateau to the swing that confirmed the extremum, so a minimum near the window edge (sin(x + 4.24) on [0, 2pi], true x = 0.472) was reported at 0.566 - a whole one-decimal bucket off, and worse than the raw argmax. - graphListen.js: centroidOf walks outward from the extreme sample while the curve stays within delta, instead of integrating from the previous swing's confirmation point. The segStart bookkeeping is gone. Header documents the inherent flicker of a barely-prominent extremum under noise of the same size. - graphListen.test.js: three hand-built cases that fail without the helpers - a root whose bracketing pair says 3.05 while the band fit says 3.0; a flat-topped peak whose argmax is 4 and whose plateau centre is 5; and the shifted sine, both extrema within half a sample step of the truth. - mastery.js: priorOf reads a numeric string (state that round-tripped through localStorage) instead of restarting the learner at pInit; test. Tests: 187 -> 191. Both helper mutants are now killed. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This was referenced Sep 10, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
שני פריטים מתוך
~/market-research/MelodyMath-spark-backlog.md— M1 ואחריו M2 — על ענף אחד, קומיט אחד לכל פריט.origin/masterבנקודהeee10e1npm test)הבדיקות דטרמיניסטיות לחלוטין: בלי
Math.random, בלי טיימרים, בלי חומרת שמע, בלי רשת.שתי טעויות במספרים של ה-backlog
ה-backlog קיבע מספרים בכתב יד כקריטריוני קבלה. שניים מהם שגויים. בשני המקרים המימוש שומר על האלגוריתם הסטנדרטי והנכון, והבדיקה מקבעת את המספר שהאלגוריתם באמת מפיק — לא את המספר שנכתב ב-backlog.
טעות 1 — «תשובה נכונה אחת מ-pInit 0.2 נותנת P(known) = 0.5407»
הנוסחה הסטנדרטית של BKT, עם ברירות המחדל של ה-backlog עצמו (
pInit 0.2, pLearn 0.15, pSlip 0.1, pGuess 0.25):התוצאה הנכונה היא 0.5526, לא 0.5407. הפרש של 0.0119.
בדקתי גם את הווריאציות הסבירות, ואף אחת מהן לא נותנת 0.5407:
לא הפעלתי הנדסה לאחור על הפרמטרים ולא שיניתי את סדר השלבים כדי להגיע ל-0.5407. הבדיקה מקבעת
0.5526, וגם מוודאת במפורש שהערך רחוק מ-0.5407 ביותר מ-0.01 — כדי שאף אחד לא «יתקן» בעתיד את המימוש חזרה למספר שנכתב ביד.טעות 2 — «עשר תשובות שגויות נשארות מתחת ל-0.1»
זה בלתי אפשרי תחת המוסכמה שהטלאי הזה בחר — לא בהכרח תחת הניסוח של ה-backlog, וזה תיקון לניסוח קודם של הסעיף הזה. ה-backlog כתב ש-
bktUpdate«מחזיר את ה-posterior אחרי תצפית»; תחת הניסוח המילולי הזה (מדווחים P(L_n|obs), ומעבירים הלאה את הערך אחרי הלמידה) עשר תשובות שגויות נותנות 0.027 — מתחת ל-0.1, כפי שה-backlog ביקש. הטלאי אימץ את המוסכמה הסטנדרטית של Corbett-Anderson:bktUpdateמחזיר את ה-prior אחרי המעבר (P(L_{n+1})), ו-bktPosteriorנותן את ה-posterior לבד. תחת המוסכמה הזו שלב העדות מחזיר הסתברות, כלומר תמיד>= 0, ולכן שלב הלמידה נותן:כלומר
pLearn = 0.15היא רצפה קשיחה — P(known) לעולם לא יורד מתחת ל-0.15 עם ברירות המחדל. נקודת השבת של תשובות שגויות היא9/52 = 0.1730769…, ואחרי עשר תשובות שגויות מ-0.2 מתקבל 0.1731.הבדיקה מקבעת 0.1731, מאמתת את רצפת ה-
pLearnעל פני 40 תשובות שגויות רצופות, ומראה שמתחת ל-0.1 כן ניתן להגיע — אבל רק עםpLearnקטן יותר (למשל 0.02).זו בחירת מוסכמה, לא טעות חשבונית של ה-backlog. אם עדיפה לך המוסכמה של ה-backlog —
bktPosteriorכבר קיים, וההחלפה היא שורה אחת ב-masteryFromHistory. (טעות 1 לא תלויה במוסכמה: גם ה-posterior לבד נותן 0.4737, לא 0.5407.)שני קריטריוני הקבלה האחרים של M1 — תקינים כפי שנכתבו
M1 — Bayesian Knowledge Tracing להערכת שליטה
קבצים:
src/lib/mastery.js,src/lib/adaptive.js,test/mastery.test.js,test/adaptive.test.jsmastery.jsמקבל את נוסחת הנסיגה הדו-שלבית הסטנדרטית של BKT:bktPosterior(state, correct, params)— שלב העדות בלבד (בייס על התשובה).bktUpdate(state, correct, params)— שני השלבים; זה הערך ש-P(known) ממשיך איתו הלאה.masteryFromHistory(history, params)— קיפול היסטוריה; מקבל גם מערך בוליאני וגם רשומות{id, correct}שהלוג כבר שומר.masteryBand(p)— הרצועות:< 0.4קל יותר,0.4–0.85אותו הדבר,> 0.85קשה יותר.stateמתקבל כמספר, כאובייקט עםpKnown/p, או ריק (=pInit). פרמטרים מחוץ לתחום נחתכים ל-[0,1] במקום להחזירNaN.adaptive.jsמקבל אתnextLevelFromMastery,paceLevelו-masteryOf. שער תוכנית הלימודים של כיתה ב׳ (gateItemsוחבריו) לא נגעתי בו בכלל — הוא כלל פדגוגי ונשאר ספירה קשיחה.למה הבחירה ב-BKT היא opt-in ולא ברירת מחדל
ה-backlog מבקש שני דברים שלא יכולים להתקיים יחד:
הן סותרות זו את זו, וזו האריתמטיקה:
0.4–0.85→ מחזיק ברמהtest/banks.test.js:153מקבע רמה 2 אחרי בדיוק שתי תשובות נכונות, כי זה מה שכלל הרצף עושה. תחת BKT הרמה עולה רק בתשובה הנכונה השלישית.לכן ברירת המחדל נשארה
'streak'ושום התנהגות קיימת לא השתנתה, ו-{ pace: 'bkt' }בוחר את כלל P(known). היפוך ברירת המחדל הוא שינוי של שורה אחת כאן ועוד ציפייה אחת ב-banks.test.js— זו החלטה פדגוגית של הבעלים, ולא של הפאטץ' הזה. לא שיניתי בדיקה קיימת כדי להכשיר את השינוי שלי.יתרון ה-BKT מודגם בבדיקה: כלל הרצף רואה רק את שתי התשובות האחרונות, ולכן אחרי חמש נכונות ואז מעידה אחת הוא «מחזיק»; BKT עדיין מחזיק חמש הצלחות בספרים (
P = 0.984910) ומעלה רמה.M2 — זיהוי נקודות ציון עמיד לרעש ל«להאזין לגרף»
קבצים:
src/lib/graphListen.js,test/graphListen.test.jsfindRootsו-findExtremaהקיימות הן קריאה מילולית של הדגימה: כל שינוי סימן הוא שורש, כל גל של שלוש דגימות הוא קיצון. על הסינוס של שיעורי ההאזנה בתוספת ריצוד של ±0.01 זה נותן 22 «קיצונים» במקום 2.findLandmarks(samples, { epsilon })היא הקריאה העמידה של אותן דגימות, ואינה משנה דבר בפונקציות הקיימות — הן נשארות מיוצאות ועוברות את הבדיקות שלהן.epsilonמטווח ה-y הנדגם (ברירת מחדל 0.05). מיקום ה-x שלו הוא מרכז המסה המשוקלל של הרמה סביבו ולא ה-argmax הגולמי: ליד שיא העקומה ריבועית, ולכן כמה דגימות הצידה הירידה קטנה מהרעש — ה-argmax נודד בכמה דגימות, מרכז המסה לא.רעש/שיפוע. איפה שכמה דגימות יושבות באותה רצועתepsilonסביב הציר, מותאם קו בריבועים פחותים דרך כולן, וזה ממצע את הרעש כלפי מטה.מדיניות הקצוות (נבחרה ותועדה — קריטריון הקבלה תלוי בה)
epsilonוגם במרחק של דגימה או שתיים מהציר לפי השיפוע המקומי, כי מעבר לקצה החלון לא ניתן לצפות בשינוי סימן כלל.החצי השני של התנאי חשוב:
1/xעל[-4, 4]פורש 20 יחידות, ולכן0.25נמצא בנוחות בתוךepsilonמהטווח. בלי מבחן השיפוע,y(-4) = -0.25היה מוכרז כחציית ציר. הוא איננו כזה, והבדיקה מוודאת שאין שורשים מדווחים שם.לכן
sin(x)על[0, 2π]ב-200 דגימות נותן 2 קיצונים (π/2,3π/2) ו-3 שורשים (0,π,2π) — בדיוק כפי שה-backlog ביקש.תיאור בעברית
describeLandmarksHeמונה את נקודות הציון משמאל לימין, ספרה עשרונית אחת לכל אחת, ושומר על ההסתייגות הקבועה של המודול — שזה תיאור של הדגימה על המסך ולא «קול הפונקציה».הארגומנט השני (אופציונלי) מפריד בין «שטוח» לבין «לא נמצאו נקודות ציון»: ל-
y = x + 1על[0, 2]אין נקודות ציון גם כן, ולקרוא לעקומה הזו שטוחה זה פשוט שקר. פונקציה קבועה מקבלת משפט שאומר במפורש שהיא שטוחה.הריצוד — ומה אימתתי לפני שקיבעתי
הריצוד הוא טבלה קבועה של 32 ערכים ב-
[-0.01, 0.01], כתובה כליטרלים בקובץ הבדיקה.עם הסינוס המרוצד רשימת נקודות הציון זהה לזו של הסינוס הנקי — סוג מול סוג, ועד ספרה עשרונית אחת — ומפיקה את אותו משפט עברית בדיוק.
כדי לוודא שהטבלה שקיבעתי מייצגת ולא מזל, בדקתי מול 200 טבלאות שנוצרו באופן בלתי תלוי, כל אחת ב-4 היסטים של פאזה — 800/800 זהות.
הערה על יושר: לפני שהוספתי את שלב הריבועים הפחותים לשורשים, 14 מתוך אותן 800 הציגו את השורש ב-
πכ-3.2במקום3.1.π = 3.14159רחוק רק 0.008 מגבול העיגול3.15, וזוג דגימות בודד פשוט לא מסוגל למקם אפס בדיוק כזה תחת רעש של 0.01. שלב ההתאמה תיקן את זה בצורה אמיתית (השגיאה ירדה מ-0.0068 ל-0.0014), ולא על ידי בחירת טבלה נוחה.שום מספר של M2 לא היה שגוי: 2 קיצונים ו-3 חציות אפס הם בדיוק מה שהאלגוריתם הנכון מפיק, ברגע שמדיניות הקצוות מנוסחת במפורש.
מה לא נעשה
{ pace: 'bkt' }ל-index.htmlאו ל-core.js. הפאטץ' נותן את היכולת ואת הבדיקות; החלפת הכלל הפדגוגי בפועל היא החלטה של הבעלים (ראו הסתירה ב-M1 למעלה).אימות עצמאי ותיקון המשך (10.9, אחר הצהריים)
סוכן אימות נפרד חישב מחדש כל מספר מקובע מההגדרה עם שברים מדויקים (הכול תאם עד הספרה האחרונה), הריץ 2,400 ניסויי רעש בלתי תלויים, ועשה mutation testing על 14 מוטנטים. שניים שרדו, ושניהם חשובים:
refineCrossing(מיקום שורש בריבועים פחותים) ו-centroidOf(מרכז הרמה של קיצון) לא היו מכוסים: מחיקת כל אחד מהם השאירה 187/187 ירוק, למרות שנמדד שהם מקטינים שגיאה פי 11 ופי 2.3y = x − 3עם שתי דגימות מסוגרות שהוזזו (הזוג המסוגר אומר 3.05, ההתאמה אומרת 3.0), ופסגה שטוחה (argmax אומר 4, מרכז הרמה 5)centroidOfליד קצה החלון: מינימום ב-x = 0.472 דווח כ-0.566 — bucket שלם בקריאה לעשרונית אחת, וגרוע פי 2.6 מה-argmax שהוא נועד לשפר. הסיבה: המרכז חושב רק מנקודת אישור התנודה הקודמת, שיושבת בתוך הרמה כשהקיצון הקודם קרובcentroidOfהולך עכשיו החוצה מהדגימה הקיצונית לשני הכיוונים כל עוד העקומה בתוך delta ממנה, במקום להיחתך לתנודה שאישרה. בדיקה חדשה מקבעתsin(x + 4.24): המינימום נקרא ב-0.472 (בתוך חצי צעד דגימה), וגם המקסימוםbktPosteriorנותן את השנייהpriorOfזרק בשקט מחרוזת מספרית ('0.9'← נקרא כ-pInit); רלוונטי כי המצב עובר דרך localStorageהבדיקות: 187 → 191, ירוק.
banks.test.jsוכל בדיקה קודמת — ללא שינוי.🤖 Generated with Claude Code