Skip to content

feat: BKT mastery estimate (M1) and noise-tolerant landmark detection (M2) - #25

Draft
Swissystem7 wants to merge 3 commits into
masterfrom
feat/heavy-backlog-m1-m2
Draft

Swissystem7 wants to merge 3 commits into
masterfrom
feat/heavy-backlog-m1-m2

Conversation

@Swissystem7

@Swissystem7 Swissystem7 commented Sep 10, 2026

Copy link
Copy Markdown
Owner

שני פריטים מתוך ~/market-research/MelodyMath-spark-backlog.md — M1 ואחריו M2 — על ענף אחד, קומיט אחד לכל פריט.

בסיס origin/master בנקודה eee10e1
סוויטה לפני (npm test) 161 עוברים, 0 נכשלים
סוויטה אחרי M1 177 עוברים, 0 נכשלים
סוויטה אחרי M2 (סופי) 187 עוברים, 0 נכשלים
שינוי 919 שורות נוספו, 7 נמחקו (כל שבע הן שורות הערה, חתימת פונקציה ורשימת ייצוא שנכתבו מחדש — שום לוגיקה קיימת לא הוסרה)

הבדיקות דטרמיניסטיות לחלוטין: בלי 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.2 × 0.9 / (0.2 × 0.9 + 0.8 × 0.25)
                  = 0.18 / 0.38  =  9/19   =  0.4736842…

שלב למידה:        9/19 + (10/19) × 0.15
                  = 10.5/19      =  21/38  =  0.5526315…

התוצאה הנכונה היא 0.5526, לא 0.5407. הפרש של 0.0119.

בדקתי גם את הווריאציות הסבירות, ואף אחת מהן לא נותנת 0.5407:

חישוב תוצאה
שלב העדות בלבד, בלי שלב הלמידה 0.4737
שלב למידה לפני שלב העדות 0.6288
שני השלבים בסדר הנכון (המימוש כאן) 0.5526
ה-backlog 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, ולכן שלב הלמידה נותן:

P_next = post + (1 − post) × pLearn  ≥  0 + 1 × 0.15  =  pLearn

כלומר 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 — תקינים כפי שנכתבו

  • מונוטוניות: תשובות נכונות נוספות אף פעם לא מורידות את P(known). ✅ מאומת על 25 צעדים ועל כל תחילית של היסטוריה.
  • עשר תשובות נכונות עוברות 0.95: ✅ מתקבל 1.0000. (למעשה הסף נפרץ כבר בתשובה השלישית: 0.9585.)

M1 — Bayesian Knowledge Tracing להערכת שליטה

קבצים: src/lib/mastery.js, src/lib/adaptive.js, test/mastery.test.js, test/adaptive.test.js

mastery.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 מבקש שני דברים שלא יכולים להתקיים יחד:

  1. «לבחור את הרמה הבאה מ-P(known) במקום מרצפים גולמיים», וגם
  2. «לשמור על הבדיקות הקיימות עוברות (להוסיף, לא לשכתב)».

הן סותרות זו את זו, וזו האריתמטיקה:

היסטוריה P(known) רצועת BKT כלל הרצף הקיים
שתי תשובות נכונות 0.843952 בתוך 0.4–0.85מחזיק ברמה עולה רמה
שלוש תשובות נכונות 0.958476 מעל 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.js

findRoots ו-findExtrema הקיימות הן קריאה מילולית של הדגימה: כל שינוי סימן הוא שורש, כל גל של שלוש דגימות הוא קיצון. על הסינוס של שיעורי ההאזנה בתוספת ריצוד של ±0.01 זה נותן 22 «קיצונים» במקום 2.

findLandmarks(samples, { epsilon }) היא הקריאה העמידה של אותן דגימות, ואינה משנה דבר בפונקציות הקיימות — הן נשארות מיוצאות ועוברות את הבדיקות שלהן.

  • בולטות (prominence): קיצון מדווח רק אחרי שהעקומה נסוגה ממנו ביותר מ-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, π, ) — בדיוק כפי שה-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 חציות אפס הם בדיוק מה שהאלגוריתם הנכון מפיק, ברגע שמדיניות הקצוות מנוסחת במפורש.


מה לא נעשה

אימות עצמאי ותיקון המשך (10.9, אחר הצהריים)

סוכן אימות נפרד חישב מחדש כל מספר מקובע מההגדרה עם שברים מדויקים (הכול תאם עד הספרה האחרונה), הריץ 2,400 ניסויי רעש בלתי תלויים, ועשה mutation testing על 14 מוטנטים. שניים שרדו, ושניהם חשובים:

ממצא מה נעשה
refineCrossing (מיקום שורש בריבועים פחותים) ו-centroidOf (מרכז הרמה של קיצון) לא היו מכוסים: מחיקת כל אחד מהם השאירה 187/187 ירוק, למרות שנמדד שהם מקטינים שגיאה פי 11 ופי 2.3 נוספו שתי בדיקות בנויות ביד שנכשלות בלעדיהם: שורש של y = 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 (בתוך חצי צעד דגימה), וגם המקסימום
הניסוח «בלתי אפשרי מעצם הבנייה» בטעות 2 היה חזק מדי — תחת הניסוח המילולי של ה-backlog הקריטריון מתקיים סעיף טעות 2 נוסח מחדש למעלה: זו בחירת מוסכמה, ו-bktPosterior נותן את השנייה
priorOf זרק בשקט מחרוזת מספרית ('0.9' ← נקרא כ-pInit); רלוונטי כי המצב עובר דרך localStorage מחרוזת מספרית נקראת; בדיקה
מגבלה מובנית: קיצון שהחזרה שלו רק בקושי עוברת את delta מהבהב תחת רעש באותו גודל (2 מתוך 2,400 ניסויים, פאזה 2.04) תועד בכותרת המודול כמדיניות, לא כבאג

הבדיקות: 187 → 191, ירוק. banks.test.js וכל בדיקה קודמת — ללא שינוי.

🤖 Generated with Claude Code

Swissystem7 and others added 3 commits September 10, 2026 14:34
…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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants