Skip to content

fix(mastery): one set of tier thresholds, cited not copied (#557) - #570

Merged
AndresL230 merged 2 commits into
mainfrom
fix/557-unify-mastery-tiers
Aug 22, 2026
Merged

fix(mastery): one set of tier thresholds, cited not copied (#557)#570
AndresL230 merged 2 commits into
mainfrom
fix/557-unify-mastery-tiers

Conversation

@AndresL230

@AndresL230AndresL230 commented Aug 22, 2026

Copy link
Copy Markdown
Collaborator

Closes#557. Workstream H5 of epic #537.

The problem

Three sets of mastery thresholds existed:

surfacemasteredweak
config.get_mastery_tier (canonical — the Tree, seeds, graph writes)≥ 0.75< 0.45 ("struggling"/"unexplored")
agents/tools/chat_context.py (tutor progress)≥ 0.7< 0.4
routes/flashcards.py (weak-concept picker)< 0.4

So in one session a concept at 0.72 read "learning" on the Tree while the tutor counted it mastered, and one at 0.42 read "struggling" on the Tree while the tutor counted it in progress and flashcards refused to drill it. Three surfaces disagreeing about the same number.

The fix

config.py owns the thresholds as named constants plus two predicates, is_mastered / is_weak. The other two sites call them.

is_weak means "below the learning floor" — struggling or unexplored — because that union is what every caller is actually asking ("which concepts need work"). Splitting it would push the union back out to the call sites, which is exactly where the drift came from.

The tutor's copy carried a comment asserting the duplication was deliberate, so "the agent's definitions of 'mastered' / 'weak' can evolve independently". They didn't evolve, they drifted — so that rationale is replaced with the rule the issue asks for: a genuinely different cut gets a named constant in config.py, never a literal at the call site.

Behaviour changes

All three are the point of the issue, not incidental:

  • tutor mastered_count: 0.7 → 0.75
  • tutor weak_count: 0.4 → 0.45
  • flashcards' weak-concept picker: 0.4 → 0.45 — so concepts in [0.4, 0.45), which the Tree already calls "struggling", are now offered for practice. The surface whose entire job is drilling weak concepts had been silently skipping a slice of them.

0.4 was never a tier boundary in the first place; it was a third number nobody reconciled.

Tests

They pin the agreement, not the numbers, so the thresholds stay movable in one place:

  • a sweep across every tier and every boundary — including the 0.4–0.45 and 0.7–0.75 bands where the two vocabularies disagreed — asserting the tutor's classification matches get_mastery_tier for the same score, driven through the real tool rather than through its constants, so reintroducing a local threshold anywhere on that path fails;
  • a guard that the local constants are gone, since the failure mode was three copies drifting apart rather than one wrong number;
  • flashcards now returns a concept at 0.42 and not one at 0.5.

Verification

Hermetic 2169 passed / 9 skipped, ruff clean, oracles 0 findings, integration 56 passed, Playwright 47 passed (the single failure is #566, red on main, fixed by #568).

🤖 Generated with Claude Code

Summary by CodeRabbit

  • Improvements

    • Unified mastery-tier classification across progress tracking, tutoring, and flashcards.
    • Weak concepts are now identified consistently, including struggling and unexplored concepts.
    • Updated mastery boundaries improve consistency in progress summaries and recommendations.
  • Tests

    • Added coverage for mastery tiers, boundary scores, tutor progress, and flashcard selection.

Three sets existed. `config.get_mastery_tier` said mastered >= 0.75 /
learning >= 0.45 / struggling >= 0.1; the tutor's progress tool carried its
own 0.7 / 0.4; flashcards drilled an ad-hoc < 0.4. So a concept at 0.72 read
"learning" on the Tree while the tutor counted it mastered, and one at 0.42
read "struggling" on the Tree while the tutor counted it in-progress AND
flashcards refused to drill it — three surfaces disagreeing about the same
number in the same session.
`config.py` now owns the thresholds as named constants plus two predicates,
`is_mastered` / `is_weak`, and the other two sites call them. `is_weak` is
"below the learning floor" — struggling OR unexplored — because that union
is what every caller is actually asking for (which concepts need work), and
splitting it would push the union back out to the call sites, which is where
the drift came from.
The tutor's local copy carried a comment claiming the duplication was
deliberate, so "the agent's definitions can evolve independently". They did
not evolve; they drifted. That rationale is replaced with the rule the issue
asks for: a genuinely different cut gets a named constant in `config.py`,
never a literal at the call site.
Behaviour changes, all of them the point of the issue:
* tutor `mastered_count` 0.7 -> 0.75
* tutor `weak_count` 0.4 -> 0.45
* flashcards' weak-concept picker 0.4 -> 0.45, so concepts in [0.4, 0.45) —
"struggling" on the Tree — are now offered for practice. The surface
whose whole job is drilling weak concepts had been skipping a slice.
Tests pin the AGREEMENT, not the numbers, so the thresholds stay movable in
one place: a sweep across every tier and boundary asserts the tutor's
classification matches `get_mastery_tier` for the same score, driven through
the real tool rather than its constants. Plus a guard that the local
constants are gone, since the failure mode was three copies drifting, not
one wrong number.
Hermetic 2169 passed / 9 skipped, ruff clean.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@cloudflare-workers-and-pages

cloudflare-workers-and-pagesBot commented Aug 22, 2026

Copy link
Copy Markdown

Deploying with Cloudflare Workers Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

StatusNameLatest CommitPreview URLUpdated (UTC)
✅ Deployment successful!
View logs
frontend-staging37e4c15Commit Preview URL

Branch Preview URL
Aug 22 2026, 08:34 AM

@supabase

supabaseBot commented Aug 22, 2026

Copy link
Copy Markdown

This pull request has been ignored for the connected project ybgqdonkoqftwrmweuyv because there are no changes detected in supabase directory. You can change this behaviour in Project Integrations Settings ↗︎.


Preview Branches by Supabase.
Learn more about Supabase Branching ↗︎.

@coderabbitai

coderabbitaiBot commented Aug 22, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@AndresL230, you've reached your PR review limit, so we couldn't start this review.

Next review available in:54 minutes

Limit details: You’ve used the included review currently available.

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

How can I continue?

Wait for the limit to reset, then comment @coderabbitai review or push new commits to the PR.

An organization admin can change what happens after included review limits in Billing.

How do review limits work?

CodeRabbit enforces per-developer PR review limits within each organization.

For paid Pro and Pro+ reviews, CodeRabbit uses a developer's included PR review attempts over the past 7 days to set the current hourly allowance. At typical activity levels, the full plan allowance applies. Higher sustained activity can lower the allowance until earlier attempts leave the 7-day window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 35c12de2-5581-4c8f-9768-569213b48633

📥 Commits

Reviewing files that changed from the base of the PR and between be9912f and 37e4c15.

📒 Files selected for processing (3)
  • backend/config.py
  • backend/routes/flashcards.py
  • backend/tests/test_mastery_tier_unification.py

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 0b53a7ad-07cd-4203-bcf3-e4df2d2f7fbc

📥 Commits

Reviewing files that changed from the base of the PR and between e5e8037 and be9912f.

📒 Files selected for processing (5)
  • backend/agents/tools/chat_context.py
  • backend/config.py
  • backend/routes/flashcards.py
  • backend/tests/test_chat_context_tools.py
  • backend/tests/test_mastery_tier_unification.py

Included review availability: Your plan provides up to 1 included review per hour; 0 remain after this review.


📝 Walkthrough

Walkthrough

Mastery thresholds are centralized in backend/config.py. Tutor progress and flashcard selection now use shared mastery predicates. Tests verify tier boundaries and consistent classification across both features.

Changes

Mastery tier unification

Layer / File(s)Summary
Centralized mastery classification
backend/config.py
Named thresholds define mastery tiers. get_mastery_tier, is_mastered, and is_weak use the shared thresholds.
Tutor and flashcard classification
backend/agents/tools/chat_context.py, backend/routes/flashcards.py
Tutor progress and weak-concept selection use shared predicates instead of local numeric thresholds.
Unified classification tests
backend/tests/test_chat_context_tools.py, backend/tests/test_mastery_tier_unification.py
Tests cover threshold boundaries, progress aggregation, tutor classification, and flashcard selection.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Merge Risk:⚪ Minimal · up to be991

This PR centralizes mastery thresholds and aligns tutor progress and flashcard selection with the canonical behavior; no actionable merge-blocking risk remains after normal checks and review.

Suggested reviewers:jose-gael-cruz-lopez, darkest-teddy

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check nameStatusExplanationResolution
Docstring Coverage⚠️ WarningDocstring coverage is 75.00% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 12 functions across 5 files.Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check nameStatusExplanation
Title check✅ PassedThe title clearly identifies the mastery-threshold unification, which is the main change in the pull request.
Description check✅ PassedThe description explains the problem, implementation, behavior changes, related issues, and test results, despite not following the template headings exactly.
Linked Issues check✅ PassedThe changes satisfy issue #557 by centralizing thresholds, removing local duplication, and validating agreement across the affected surfaces.
Out of Scope Changes check✅ PassedAll summarized changes support the threshold-unification objectives in issue #557, with no unrelated code changes identified.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/557-unify-mastery-tiers

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

Two review findings, both about claims this PR made that weren't yet true.
**A fourth copy survived, in TypeScript.** `Learn.tsx::tierForScore`
re-declares 0.75/0.45/0.1 to classify a STREAMED mastery delta client-side,
so the live Tree matches the refetch that follows. It cannot import from
`config.py` — but the header I added asserted "every surface reads them from
here", and the guard test only checked that two Python attribute names were
absent. So moving a threshold would have painted a node one tier live and a
different tier on the next refetch: #557's own bug, across the wire instead
of across two modules. The mirror is now pinned by a test that reads the TSX
and asserts the numbers match, and the header says the mirror exists.
**Widening the floor made an unsorted cap unsafe.** `_get_weak_concepts`
takes the first 15 qualifying rows in PostgREST order. That was survivable
at `< 0.4`; at `< 0.45` the newly-admitted [0.4, 0.45) concepts can displace
0.0-0.1 ones on arbitrary row order, so the surface whose job is drilling the
weakest concepts could drill the least-weak of the weak — a regression
created by this PR's own widening. Sorted ascending before the cap, which
also makes the truncation deterministic.
Hermetic 2171 passed / 9 skipped, ruff clean.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@AndresL230

Copy link
Copy Markdown
CollaboratorAuthor

Review round — two findings, both fixed in 37e4c154

Both were about claims this PR made that weren't yet true.

A fourth copy survived, in TypeScript.frontend/src/components/screens/Learn.tsx::tierForScore re-declares 0.75 / 0.45 / 0.1 to classify a streamed mastery delta client-side, so the live Tree agrees with the refetch that follows it. It can't import from config.py — but the header I added asserted "every surface reads them from here", and the guard test only checked that two Python attribute names were absent. So moving MASTERY_LEARNING_MIN to 0.5 would paint a node landing at 0.47 as "learning" live and "struggling" one refetch later: #557's exact bug, across the wire instead of across two modules. Now pinned by a test that reads the TSX and asserts the numbers match, and the header names the mirror instead of pretending it doesn't exist.

Widening the floor made an unsorted cap unsafe — a regression this PR created._get_weak_concepts returned the first 15 qualifying rows in PostgREST order. Harmless at < 0.4; at < 0.45 the newly-admitted [0.4, 0.45) concepts can displace 0.00.1 ones purely on row order, so the surface whose entire job is drilling the weakest concepts could end up drilling the least-weak of the weak. Sorted ascending before the cap, which also makes the truncation deterministic rather than dependent on row order.

Worth noting the review also verified the part I'd have most wanted checked: graph_service.py:734/778 are the only writers of mastery_score, and both write mastery_tier=get_mastery_tier(...) alongside — so the denormalized column the Tree reads stays in sync with the score the new predicates read. The unification actually holds rather than just looking like it does.

Verification

Hermetic 2171 passed / 9 skipped, ruff clean, oracles 0 findings, integration 56 passed, Playwright 47 passed (the one failure is #566, red on main, fixed by #568). CI green.

@AndresL230
AndresL230 merged commit c7f043c into mainAug 22, 2026
8 checks passed
Sign up for freeto 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.

quiz H5: unify the three divergent mastery-tier threshold sets

1 participant

@AndresL230
, 'i'); if (__m === '*' || __re.test(location.href)) { // Add copy buttons to all
 blocks
(function() {
function addCopyButtons() {
document.querySelectorAll('pre code').forEach(function(codeBlock) {
if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;
codeBlock.parentElement.setAttribute('data-copy-added', 'true');
var btn = document.createElement('button');
btn.textContent = 'Copy';
btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';
btn.onmouseover = function() { this.style.opacity = '1'; };
btn.onmouseout = function() { this.style.opacity = '0.7'; };
btn.onclick = function() {
navigator.clipboard.writeText(codeBlock.textContent).then(function() {
btn.textContent = 'Copied!';
setTimeout(function() { btn.textContent = 'Copy'; }, 1500);
});
};
codeBlock.parentElement.style.position = 'relative';
codeBlock.parentElement.appendChild(btn);
});
}
addCopyButtons();
// Re-run on dynamic content
var observer = new MutationObserver(addCopyButtons);
observer.observe(document.body, { childList: true, subtree: true });
})();
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
fix(mastery): one set of tier thresholds, cited not copied (#557) by AndresL230 · Pull Request #570 · SaplingLearn/Sapling · GitHub
Skip to content

fix(mastery): one set of tier thresholds, cited not copied (#557) - #570

Merged
AndresL230 merged 2 commits into
mainfrom
fix/557-unify-mastery-tiers
Aug 22, 2026
Merged

fix(mastery): one set of tier thresholds, cited not copied (#557)#570
AndresL230 merged 2 commits into
mainfrom
fix/557-unify-mastery-tiers

Conversation

@AndresL230

@AndresL230AndresL230 commented Aug 22, 2026

Copy link
Copy Markdown
Collaborator

Closes#557. Workstream H5 of epic #537.

The problem

Three sets of mastery thresholds existed:

surfacemasteredweak
config.get_mastery_tier (canonical — the Tree, seeds, graph writes)≥ 0.75< 0.45 ("struggling"/"unexplored")
agents/tools/chat_context.py (tutor progress)≥ 0.7< 0.4
routes/flashcards.py (weak-concept picker)< 0.4

So in one session a concept at 0.72 read "learning" on the Tree while the tutor counted it mastered, and one at 0.42 read "struggling" on the Tree while the tutor counted it in progress and flashcards refused to drill it. Three surfaces disagreeing about the same number.

The fix

config.py owns the thresholds as named constants plus two predicates, is_mastered / is_weak. The other two sites call them.

is_weak means "below the learning floor" — struggling or unexplored — because that union is what every caller is actually asking ("which concepts need work"). Splitting it would push the union back out to the call sites, which is exactly where the drift came from.

The tutor's copy carried a comment asserting the duplication was deliberate, so "the agent's definitions of 'mastered' / 'weak' can evolve independently". They didn't evolve, they drifted — so that rationale is replaced with the rule the issue asks for: a genuinely different cut gets a named constant in config.py, never a literal at the call site.

Behaviour changes

All three are the point of the issue, not incidental:

  • tutor mastered_count: 0.7 → 0.75
  • tutor weak_count: 0.4 → 0.45
  • flashcards' weak-concept picker: 0.4 → 0.45 — so concepts in [0.4, 0.45), which the Tree already calls "struggling", are now offered for practice. The surface whose entire job is drilling weak concepts had been silently skipping a slice of them.

0.4 was never a tier boundary in the first place; it was a third number nobody reconciled.

Tests

They pin the agreement, not the numbers, so the thresholds stay movable in one place:

  • a sweep across every tier and every boundary — including the 0.4–0.45 and 0.7–0.75 bands where the two vocabularies disagreed — asserting the tutor's classification matches get_mastery_tier for the same score, driven through the real tool rather than through its constants, so reintroducing a local threshold anywhere on that path fails;
  • a guard that the local constants are gone, since the failure mode was three copies drifting apart rather than one wrong number;
  • flashcards now returns a concept at 0.42 and not one at 0.5.

Verification

Hermetic 2169 passed / 9 skipped, ruff clean, oracles 0 findings, integration 56 passed, Playwright 47 passed (the single failure is #566, red on main, fixed by #568).

🤖 Generated with Claude Code

Summary by CodeRabbit

  • Improvements

    • Unified mastery-tier classification across progress tracking, tutoring, and flashcards.
    • Weak concepts are now identified consistently, including struggling and unexplored concepts.
    • Updated mastery boundaries improve consistency in progress summaries and recommendations.
  • Tests

    • Added coverage for mastery tiers, boundary scores, tutor progress, and flashcard selection.

Three sets existed. `config.get_mastery_tier` said mastered >= 0.75 /
learning >= 0.45 / struggling >= 0.1; the tutor's progress tool carried its
own 0.7 / 0.4; flashcards drilled an ad-hoc < 0.4. So a concept at 0.72 read
"learning" on the Tree while the tutor counted it mastered, and one at 0.42
read "struggling" on the Tree while the tutor counted it in-progress AND
flashcards refused to drill it — three surfaces disagreeing about the same
number in the same session.
`config.py` now owns the thresholds as named constants plus two predicates,
`is_mastered` / `is_weak`, and the other two sites call them. `is_weak` is
"below the learning floor" — struggling OR unexplored — because that union
is what every caller is actually asking for (which concepts need work), and
splitting it would push the union back out to the call sites, which is where
the drift came from.
The tutor's local copy carried a comment claiming the duplication was
deliberate, so "the agent's definitions can evolve independently". They did
not evolve; they drifted. That rationale is replaced with the rule the issue
asks for: a genuinely different cut gets a named constant in `config.py`,
never a literal at the call site.
Behaviour changes, all of them the point of the issue:
* tutor `mastered_count` 0.7 -> 0.75
* tutor `weak_count` 0.4 -> 0.45
* flashcards' weak-concept picker 0.4 -> 0.45, so concepts in [0.4, 0.45) —
"struggling" on the Tree — are now offered for practice. The surface
whose whole job is drilling weak concepts had been skipping a slice.
Tests pin the AGREEMENT, not the numbers, so the thresholds stay movable in
one place: a sweep across every tier and boundary asserts the tutor's
classification matches `get_mastery_tier` for the same score, driven through
the real tool rather than its constants. Plus a guard that the local
constants are gone, since the failure mode was three copies drifting, not
one wrong number.
Hermetic 2169 passed / 9 skipped, ruff clean.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@cloudflare-workers-and-pages

cloudflare-workers-and-pagesBot commented Aug 22, 2026

Copy link
Copy Markdown

Deploying with Cloudflare Workers Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

StatusNameLatest CommitPreview URLUpdated (UTC)
✅ Deployment successful!
View logs
frontend-staging37e4c15Commit Preview URL

Branch Preview URL
Aug 22 2026, 08:34 AM

@supabase

supabaseBot commented Aug 22, 2026

Copy link
Copy Markdown

This pull request has been ignored for the connected project ybgqdonkoqftwrmweuyv because there are no changes detected in supabase directory. You can change this behaviour in Project Integrations Settings ↗︎.


Preview Branches by Supabase.
Learn more about Supabase Branching ↗︎.

@coderabbitai

coderabbitaiBot commented Aug 22, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@AndresL230, you've reached your PR review limit, so we couldn't start this review.

Next review available in:54 minutes

Limit details: You’ve used the included review currently available.

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

How can I continue?

Wait for the limit to reset, then comment @coderabbitai review or push new commits to the PR.

An organization admin can change what happens after included review limits in Billing.

How do review limits work?

CodeRabbit enforces per-developer PR review limits within each organization.

For paid Pro and Pro+ reviews, CodeRabbit uses a developer's included PR review attempts over the past 7 days to set the current hourly allowance. At typical activity levels, the full plan allowance applies. Higher sustained activity can lower the allowance until earlier attempts leave the 7-day window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 35c12de2-5581-4c8f-9768-569213b48633

📥 Commits

Reviewing files that changed from the base of the PR and between be9912f and 37e4c15.

📒 Files selected for processing (3)
  • backend/config.py
  • backend/routes/flashcards.py
  • backend/tests/test_mastery_tier_unification.py

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 0b53a7ad-07cd-4203-bcf3-e4df2d2f7fbc

📥 Commits

Reviewing files that changed from the base of the PR and between e5e8037 and be9912f.

📒 Files selected for processing (5)
  • backend/agents/tools/chat_context.py
  • backend/config.py
  • backend/routes/flashcards.py
  • backend/tests/test_chat_context_tools.py
  • backend/tests/test_mastery_tier_unification.py

Included review availability: Your plan provides up to 1 included review per hour; 0 remain after this review.


📝 Walkthrough

Walkthrough

Mastery thresholds are centralized in backend/config.py. Tutor progress and flashcard selection now use shared mastery predicates. Tests verify tier boundaries and consistent classification across both features.

Changes

Mastery tier unification

Layer / File(s)Summary
Centralized mastery classification
backend/config.py
Named thresholds define mastery tiers. get_mastery_tier, is_mastered, and is_weak use the shared thresholds.
Tutor and flashcard classification
backend/agents/tools/chat_context.py, backend/routes/flashcards.py
Tutor progress and weak-concept selection use shared predicates instead of local numeric thresholds.
Unified classification tests
backend/tests/test_chat_context_tools.py, backend/tests/test_mastery_tier_unification.py
Tests cover threshold boundaries, progress aggregation, tutor classification, and flashcard selection.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Merge Risk:⚪ Minimal · up to be991

This PR centralizes mastery thresholds and aligns tutor progress and flashcard selection with the canonical behavior; no actionable merge-blocking risk remains after normal checks and review.

Suggested reviewers:jose-gael-cruz-lopez, darkest-teddy

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check nameStatusExplanationResolution
Docstring Coverage⚠️ WarningDocstring coverage is 75.00% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 12 functions across 5 files.Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check nameStatusExplanation
Title check✅ PassedThe title clearly identifies the mastery-threshold unification, which is the main change in the pull request.
Description check✅ PassedThe description explains the problem, implementation, behavior changes, related issues, and test results, despite not following the template headings exactly.
Linked Issues check✅ PassedThe changes satisfy issue #557 by centralizing thresholds, removing local duplication, and validating agreement across the affected surfaces.
Out of Scope Changes check✅ PassedAll summarized changes support the threshold-unification objectives in issue #557, with no unrelated code changes identified.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/557-unify-mastery-tiers

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

Two review findings, both about claims this PR made that weren't yet true.
**A fourth copy survived, in TypeScript.** `Learn.tsx::tierForScore`
re-declares 0.75/0.45/0.1 to classify a STREAMED mastery delta client-side,
so the live Tree matches the refetch that follows. It cannot import from
`config.py` — but the header I added asserted "every surface reads them from
here", and the guard test only checked that two Python attribute names were
absent. So moving a threshold would have painted a node one tier live and a
different tier on the next refetch: #557's own bug, across the wire instead
of across two modules. The mirror is now pinned by a test that reads the TSX
and asserts the numbers match, and the header says the mirror exists.
**Widening the floor made an unsorted cap unsafe.** `_get_weak_concepts`
takes the first 15 qualifying rows in PostgREST order. That was survivable
at `< 0.4`; at `< 0.45` the newly-admitted [0.4, 0.45) concepts can displace
0.0-0.1 ones on arbitrary row order, so the surface whose job is drilling the
weakest concepts could drill the least-weak of the weak — a regression
created by this PR's own widening. Sorted ascending before the cap, which
also makes the truncation deterministic.
Hermetic 2171 passed / 9 skipped, ruff clean.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@AndresL230

Copy link
Copy Markdown
CollaboratorAuthor

Review round — two findings, both fixed in 37e4c154

Both were about claims this PR made that weren't yet true.

A fourth copy survived, in TypeScript.frontend/src/components/screens/Learn.tsx::tierForScore re-declares 0.75 / 0.45 / 0.1 to classify a streamed mastery delta client-side, so the live Tree agrees with the refetch that follows it. It can't import from config.py — but the header I added asserted "every surface reads them from here", and the guard test only checked that two Python attribute names were absent. So moving MASTERY_LEARNING_MIN to 0.5 would paint a node landing at 0.47 as "learning" live and "struggling" one refetch later: #557's exact bug, across the wire instead of across two modules. Now pinned by a test that reads the TSX and asserts the numbers match, and the header names the mirror instead of pretending it doesn't exist.

Widening the floor made an unsorted cap unsafe — a regression this PR created._get_weak_concepts returned the first 15 qualifying rows in PostgREST order. Harmless at < 0.4; at < 0.45 the newly-admitted [0.4, 0.45) concepts can displace 0.00.1 ones purely on row order, so the surface whose entire job is drilling the weakest concepts could end up drilling the least-weak of the weak. Sorted ascending before the cap, which also makes the truncation deterministic rather than dependent on row order.

Worth noting the review also verified the part I'd have most wanted checked: graph_service.py:734/778 are the only writers of mastery_score, and both write mastery_tier=get_mastery_tier(...) alongside — so the denormalized column the Tree reads stays in sync with the score the new predicates read. The unification actually holds rather than just looking like it does.

Verification

Hermetic 2171 passed / 9 skipped, ruff clean, oracles 0 findings, integration 56 passed, Playwright 47 passed (the one failure is #566, red on main, fixed by #568). CI green.

@AndresL230
AndresL230 merged commit c7f043c into mainAug 22, 2026
8 checks passed
Sign up for freeto 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.

quiz H5: unify the three divergent mastery-tier threshold sets

1 participant

@AndresL230
, 'i'); if (__m === '*' || __re.test(location.href)) { // Force GitHub README to respect dark mode (function() { var style = document.createElement('style'); style.textContent = ' .markdown-body { color-scheme: dark light; } .markdown-body pre { background: #161b22 !important; } .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; } .markdown-body table th, .markdown-body table td { border-color: #30363d !important; } .markdown-body img { background: #0d1117; } .markdown-body blockquote { border-left-color: #8b949e; } .markdown-body hr { border-color: #30363d; } '; document.head.appendChild(style); })(); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' fix(mastery): one set of tier thresholds, cited not copied (#557) by AndresL230 · Pull Request #570 · SaplingLearn/Sapling · GitHub
Skip to content

fix(mastery): one set of tier thresholds, cited not copied (#557) - #570

Merged
AndresL230 merged 2 commits into
mainfrom
fix/557-unify-mastery-tiers
Aug 22, 2026
Merged

fix(mastery): one set of tier thresholds, cited not copied (#557)#570
AndresL230 merged 2 commits into
mainfrom
fix/557-unify-mastery-tiers

Conversation

@AndresL230

@AndresL230AndresL230 commented Aug 22, 2026

Copy link
Copy Markdown
Collaborator

Closes#557. Workstream H5 of epic #537.

The problem

Three sets of mastery thresholds existed:

surfacemasteredweak
config.get_mastery_tier (canonical — the Tree, seeds, graph writes)≥ 0.75< 0.45 ("struggling"/"unexplored")
agents/tools/chat_context.py (tutor progress)≥ 0.7< 0.4
routes/flashcards.py (weak-concept picker)< 0.4

So in one session a concept at 0.72 read "learning" on the Tree while the tutor counted it mastered, and one at 0.42 read "struggling" on the Tree while the tutor counted it in progress and flashcards refused to drill it. Three surfaces disagreeing about the same number.

The fix

config.py owns the thresholds as named constants plus two predicates, is_mastered / is_weak. The other two sites call them.

is_weak means "below the learning floor" — struggling or unexplored — because that union is what every caller is actually asking ("which concepts need work"). Splitting it would push the union back out to the call sites, which is exactly where the drift came from.

The tutor's copy carried a comment asserting the duplication was deliberate, so "the agent's definitions of 'mastered' / 'weak' can evolve independently". They didn't evolve, they drifted — so that rationale is replaced with the rule the issue asks for: a genuinely different cut gets a named constant in config.py, never a literal at the call site.

Behaviour changes

All three are the point of the issue, not incidental:

  • tutor mastered_count: 0.7 → 0.75
  • tutor weak_count: 0.4 → 0.45
  • flashcards' weak-concept picker: 0.4 → 0.45 — so concepts in [0.4, 0.45), which the Tree already calls "struggling", are now offered for practice. The surface whose entire job is drilling weak concepts had been silently skipping a slice of them.

0.4 was never a tier boundary in the first place; it was a third number nobody reconciled.

Tests

They pin the agreement, not the numbers, so the thresholds stay movable in one place:

  • a sweep across every tier and every boundary — including the 0.4–0.45 and 0.7–0.75 bands where the two vocabularies disagreed — asserting the tutor's classification matches get_mastery_tier for the same score, driven through the real tool rather than through its constants, so reintroducing a local threshold anywhere on that path fails;
  • a guard that the local constants are gone, since the failure mode was three copies drifting apart rather than one wrong number;
  • flashcards now returns a concept at 0.42 and not one at 0.5.

Verification

Hermetic 2169 passed / 9 skipped, ruff clean, oracles 0 findings, integration 56 passed, Playwright 47 passed (the single failure is #566, red on main, fixed by #568).

🤖 Generated with Claude Code

Summary by CodeRabbit

  • Improvements

    • Unified mastery-tier classification across progress tracking, tutoring, and flashcards.
    • Weak concepts are now identified consistently, including struggling and unexplored concepts.
    • Updated mastery boundaries improve consistency in progress summaries and recommendations.
  • Tests

    • Added coverage for mastery tiers, boundary scores, tutor progress, and flashcard selection.

Three sets existed. `config.get_mastery_tier` said mastered >= 0.75 /
learning >= 0.45 / struggling >= 0.1; the tutor's progress tool carried its
own 0.7 / 0.4; flashcards drilled an ad-hoc < 0.4. So a concept at 0.72 read
"learning" on the Tree while the tutor counted it mastered, and one at 0.42
read "struggling" on the Tree while the tutor counted it in-progress AND
flashcards refused to drill it — three surfaces disagreeing about the same
number in the same session.
`config.py` now owns the thresholds as named constants plus two predicates,
`is_mastered` / `is_weak`, and the other two sites call them. `is_weak` is
"below the learning floor" — struggling OR unexplored — because that union
is what every caller is actually asking for (which concepts need work), and
splitting it would push the union back out to the call sites, which is where
the drift came from.
The tutor's local copy carried a comment claiming the duplication was
deliberate, so "the agent's definitions can evolve independently". They did
not evolve; they drifted. That rationale is replaced with the rule the issue
asks for: a genuinely different cut gets a named constant in `config.py`,
never a literal at the call site.
Behaviour changes, all of them the point of the issue:
* tutor `mastered_count` 0.7 -> 0.75
* tutor `weak_count` 0.4 -> 0.45
* flashcards' weak-concept picker 0.4 -> 0.45, so concepts in [0.4, 0.45) —
"struggling" on the Tree — are now offered for practice. The surface
whose whole job is drilling weak concepts had been skipping a slice.
Tests pin the AGREEMENT, not the numbers, so the thresholds stay movable in
one place: a sweep across every tier and boundary asserts the tutor's
classification matches `get_mastery_tier` for the same score, driven through
the real tool rather than its constants. Plus a guard that the local
constants are gone, since the failure mode was three copies drifting, not
one wrong number.
Hermetic 2169 passed / 9 skipped, ruff clean.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@cloudflare-workers-and-pages

cloudflare-workers-and-pagesBot commented Aug 22, 2026

Copy link
Copy Markdown

Deploying with Cloudflare Workers Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

StatusNameLatest CommitPreview URLUpdated (UTC)
✅ Deployment successful!
View logs
frontend-staging37e4c15Commit Preview URL

Branch Preview URL
Aug 22 2026, 08:34 AM

@supabase

supabaseBot commented Aug 22, 2026

Copy link
Copy Markdown

This pull request has been ignored for the connected project ybgqdonkoqftwrmweuyv because there are no changes detected in supabase directory. You can change this behaviour in Project Integrations Settings ↗︎.


Preview Branches by Supabase.
Learn more about Supabase Branching ↗︎.

@coderabbitai

coderabbitaiBot commented Aug 22, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@AndresL230, you've reached your PR review limit, so we couldn't start this review.

Next review available in:54 minutes

Limit details: You’ve used the included review currently available.

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

How can I continue?

Wait for the limit to reset, then comment @coderabbitai review or push new commits to the PR.

An organization admin can change what happens after included review limits in Billing.

How do review limits work?

CodeRabbit enforces per-developer PR review limits within each organization.

For paid Pro and Pro+ reviews, CodeRabbit uses a developer's included PR review attempts over the past 7 days to set the current hourly allowance. At typical activity levels, the full plan allowance applies. Higher sustained activity can lower the allowance until earlier attempts leave the 7-day window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 35c12de2-5581-4c8f-9768-569213b48633

📥 Commits

Reviewing files that changed from the base of the PR and between be9912f and 37e4c15.

📒 Files selected for processing (3)
  • backend/config.py
  • backend/routes/flashcards.py
  • backend/tests/test_mastery_tier_unification.py

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 0b53a7ad-07cd-4203-bcf3-e4df2d2f7fbc

📥 Commits

Reviewing files that changed from the base of the PR and between e5e8037 and be9912f.

📒 Files selected for processing (5)
  • backend/agents/tools/chat_context.py
  • backend/config.py
  • backend/routes/flashcards.py
  • backend/tests/test_chat_context_tools.py
  • backend/tests/test_mastery_tier_unification.py

Included review availability: Your plan provides up to 1 included review per hour; 0 remain after this review.


📝 Walkthrough

Walkthrough

Mastery thresholds are centralized in backend/config.py. Tutor progress and flashcard selection now use shared mastery predicates. Tests verify tier boundaries and consistent classification across both features.

Changes

Mastery tier unification

Layer / File(s)Summary
Centralized mastery classification
backend/config.py
Named thresholds define mastery tiers. get_mastery_tier, is_mastered, and is_weak use the shared thresholds.
Tutor and flashcard classification
backend/agents/tools/chat_context.py, backend/routes/flashcards.py
Tutor progress and weak-concept selection use shared predicates instead of local numeric thresholds.
Unified classification tests
backend/tests/test_chat_context_tools.py, backend/tests/test_mastery_tier_unification.py
Tests cover threshold boundaries, progress aggregation, tutor classification, and flashcard selection.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Merge Risk:⚪ Minimal · up to be991

This PR centralizes mastery thresholds and aligns tutor progress and flashcard selection with the canonical behavior; no actionable merge-blocking risk remains after normal checks and review.

Suggested reviewers:jose-gael-cruz-lopez, darkest-teddy

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check nameStatusExplanationResolution
Docstring Coverage⚠️ WarningDocstring coverage is 75.00% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 12 functions across 5 files.Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check nameStatusExplanation
Title check✅ PassedThe title clearly identifies the mastery-threshold unification, which is the main change in the pull request.
Description check✅ PassedThe description explains the problem, implementation, behavior changes, related issues, and test results, despite not following the template headings exactly.
Linked Issues check✅ PassedThe changes satisfy issue #557 by centralizing thresholds, removing local duplication, and validating agreement across the affected surfaces.
Out of Scope Changes check✅ PassedAll summarized changes support the threshold-unification objectives in issue #557, with no unrelated code changes identified.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/557-unify-mastery-tiers

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

Two review findings, both about claims this PR made that weren't yet true.
**A fourth copy survived, in TypeScript.** `Learn.tsx::tierForScore`
re-declares 0.75/0.45/0.1 to classify a STREAMED mastery delta client-side,
so the live Tree matches the refetch that follows. It cannot import from
`config.py` — but the header I added asserted "every surface reads them from
here", and the guard test only checked that two Python attribute names were
absent. So moving a threshold would have painted a node one tier live and a
different tier on the next refetch: #557's own bug, across the wire instead
of across two modules. The mirror is now pinned by a test that reads the TSX
and asserts the numbers match, and the header says the mirror exists.
**Widening the floor made an unsorted cap unsafe.** `_get_weak_concepts`
takes the first 15 qualifying rows in PostgREST order. That was survivable
at `< 0.4`; at `< 0.45` the newly-admitted [0.4, 0.45) concepts can displace
0.0-0.1 ones on arbitrary row order, so the surface whose job is drilling the
weakest concepts could drill the least-weak of the weak — a regression
created by this PR's own widening. Sorted ascending before the cap, which
also makes the truncation deterministic.
Hermetic 2171 passed / 9 skipped, ruff clean.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@AndresL230

Copy link
Copy Markdown
CollaboratorAuthor

Review round — two findings, both fixed in 37e4c154

Both were about claims this PR made that weren't yet true.

A fourth copy survived, in TypeScript.frontend/src/components/screens/Learn.tsx::tierForScore re-declares 0.75 / 0.45 / 0.1 to classify a streamed mastery delta client-side, so the live Tree agrees with the refetch that follows it. It can't import from config.py — but the header I added asserted "every surface reads them from here", and the guard test only checked that two Python attribute names were absent. So moving MASTERY_LEARNING_MIN to 0.5 would paint a node landing at 0.47 as "learning" live and "struggling" one refetch later: #557's exact bug, across the wire instead of across two modules. Now pinned by a test that reads the TSX and asserts the numbers match, and the header names the mirror instead of pretending it doesn't exist.

Widening the floor made an unsorted cap unsafe — a regression this PR created._get_weak_concepts returned the first 15 qualifying rows in PostgREST order. Harmless at < 0.4; at < 0.45 the newly-admitted [0.4, 0.45) concepts can displace 0.00.1 ones purely on row order, so the surface whose entire job is drilling the weakest concepts could end up drilling the least-weak of the weak. Sorted ascending before the cap, which also makes the truncation deterministic rather than dependent on row order.

Worth noting the review also verified the part I'd have most wanted checked: graph_service.py:734/778 are the only writers of mastery_score, and both write mastery_tier=get_mastery_tier(...) alongside — so the denormalized column the Tree reads stays in sync with the score the new predicates read. The unification actually holds rather than just looking like it does.

Verification

Hermetic 2171 passed / 9 skipped, ruff clean, oracles 0 findings, integration 56 passed, Playwright 47 passed (the one failure is #566, red on main, fixed by #568). CI green.

@AndresL230
AndresL230 merged commit c7f043c into mainAug 22, 2026
8 checks passed
Sign up for freeto 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.

quiz H5: unify the three divergent mastery-tier threshold sets

1 participant

@AndresL230
, 'i'); if (__m === '*' || __re.test(location.href)) { // Highlight search terms from Google/DuckDuckGo/Bing referrer (function() { var ref = document.referrer; var terms = []; if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) { var url = new URL(ref); var q = url.searchParams.get('q') || url.searchParams.get('p'); if (q) { terms = q.split(/\s+/).filter(function(t) { return t.length > 2; }); } } if (terms.length === 0) return; var style = document.createElement('style'); style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }'; document.head.appendChild(style); function highlight(node) { if (node.nodeType === 3) { // text node var text = node.textContent; var found = false; terms.forEach(function(term) { var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\]\\]/g, '\\') + ')', 'gi'); if (regex.test(text)) { found = true; var frag = document.createDocumentFragment(); var parts = text.split(regex); parts.forEach(function(part, i) { if (i % 2 === 0) { frag.appendChild(document.createTextNode(part)); } else { var span = document.createElement('span'); span.className = 'userscript-highlight'; span.textContent = part; frag.appendChild(span); } }); node.parentNode.replaceChild(frag, node); } }); } else if (node.nodeType === 1 && node.childNodes) { // element var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT']; if (!skipTags.includes(node.tagName)) { Array.from(node.childNodes).forEach(highlight); } } } highlight(document.body); // Re-highlight on dynamic content var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1 || node.nodeType === 3) highlight(node); }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' fix(mastery): one set of tier thresholds, cited not copied (#557) by AndresL230 · Pull Request #570 · SaplingLearn/Sapling · GitHub
Skip to content

fix(mastery): one set of tier thresholds, cited not copied (#557) - #570

Merged
AndresL230 merged 2 commits into
mainfrom
fix/557-unify-mastery-tiers
Aug 22, 2026
Merged

fix(mastery): one set of tier thresholds, cited not copied (#557)#570
AndresL230 merged 2 commits into
mainfrom
fix/557-unify-mastery-tiers

Conversation

@AndresL230

@AndresL230AndresL230 commented Aug 22, 2026

Copy link
Copy Markdown
Collaborator

Closes#557. Workstream H5 of epic #537.

The problem

Three sets of mastery thresholds existed:

surfacemasteredweak
config.get_mastery_tier (canonical — the Tree, seeds, graph writes)≥ 0.75< 0.45 ("struggling"/"unexplored")
agents/tools/chat_context.py (tutor progress)≥ 0.7< 0.4
routes/flashcards.py (weak-concept picker)< 0.4

So in one session a concept at 0.72 read "learning" on the Tree while the tutor counted it mastered, and one at 0.42 read "struggling" on the Tree while the tutor counted it in progress and flashcards refused to drill it. Three surfaces disagreeing about the same number.

The fix

config.py owns the thresholds as named constants plus two predicates, is_mastered / is_weak. The other two sites call them.

is_weak means "below the learning floor" — struggling or unexplored — because that union is what every caller is actually asking ("which concepts need work"). Splitting it would push the union back out to the call sites, which is exactly where the drift came from.

The tutor's copy carried a comment asserting the duplication was deliberate, so "the agent's definitions of 'mastered' / 'weak' can evolve independently". They didn't evolve, they drifted — so that rationale is replaced with the rule the issue asks for: a genuinely different cut gets a named constant in config.py, never a literal at the call site.

Behaviour changes

All three are the point of the issue, not incidental:

  • tutor mastered_count: 0.7 → 0.75
  • tutor weak_count: 0.4 → 0.45
  • flashcards' weak-concept picker: 0.4 → 0.45 — so concepts in [0.4, 0.45), which the Tree already calls "struggling", are now offered for practice. The surface whose entire job is drilling weak concepts had been silently skipping a slice of them.

0.4 was never a tier boundary in the first place; it was a third number nobody reconciled.

Tests

They pin the agreement, not the numbers, so the thresholds stay movable in one place:

  • a sweep across every tier and every boundary — including the 0.4–0.45 and 0.7–0.75 bands where the two vocabularies disagreed — asserting the tutor's classification matches get_mastery_tier for the same score, driven through the real tool rather than through its constants, so reintroducing a local threshold anywhere on that path fails;
  • a guard that the local constants are gone, since the failure mode was three copies drifting apart rather than one wrong number;
  • flashcards now returns a concept at 0.42 and not one at 0.5.

Verification

Hermetic 2169 passed / 9 skipped, ruff clean, oracles 0 findings, integration 56 passed, Playwright 47 passed (the single failure is #566, red on main, fixed by #568).

🤖 Generated with Claude Code

Summary by CodeRabbit

  • Improvements

    • Unified mastery-tier classification across progress tracking, tutoring, and flashcards.
    • Weak concepts are now identified consistently, including struggling and unexplored concepts.
    • Updated mastery boundaries improve consistency in progress summaries and recommendations.
  • Tests

    • Added coverage for mastery tiers, boundary scores, tutor progress, and flashcard selection.

Three sets existed. `config.get_mastery_tier` said mastered >= 0.75 /
learning >= 0.45 / struggling >= 0.1; the tutor's progress tool carried its
own 0.7 / 0.4; flashcards drilled an ad-hoc < 0.4. So a concept at 0.72 read
"learning" on the Tree while the tutor counted it mastered, and one at 0.42
read "struggling" on the Tree while the tutor counted it in-progress AND
flashcards refused to drill it — three surfaces disagreeing about the same
number in the same session.
`config.py` now owns the thresholds as named constants plus two predicates,
`is_mastered` / `is_weak`, and the other two sites call them. `is_weak` is
"below the learning floor" — struggling OR unexplored — because that union
is what every caller is actually asking for (which concepts need work), and
splitting it would push the union back out to the call sites, which is where
the drift came from.
The tutor's local copy carried a comment claiming the duplication was
deliberate, so "the agent's definitions can evolve independently". They did
not evolve; they drifted. That rationale is replaced with the rule the issue
asks for: a genuinely different cut gets a named constant in `config.py`,
never a literal at the call site.
Behaviour changes, all of them the point of the issue:
* tutor `mastered_count` 0.7 -> 0.75
* tutor `weak_count` 0.4 -> 0.45
* flashcards' weak-concept picker 0.4 -> 0.45, so concepts in [0.4, 0.45) —
"struggling" on the Tree — are now offered for practice. The surface
whose whole job is drilling weak concepts had been skipping a slice.
Tests pin the AGREEMENT, not the numbers, so the thresholds stay movable in
one place: a sweep across every tier and boundary asserts the tutor's
classification matches `get_mastery_tier` for the same score, driven through
the real tool rather than its constants. Plus a guard that the local
constants are gone, since the failure mode was three copies drifting, not
one wrong number.
Hermetic 2169 passed / 9 skipped, ruff clean.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@cloudflare-workers-and-pages

cloudflare-workers-and-pagesBot commented Aug 22, 2026

Copy link
Copy Markdown

Deploying with Cloudflare Workers Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

StatusNameLatest CommitPreview URLUpdated (UTC)
✅ Deployment successful!
View logs
frontend-staging37e4c15Commit Preview URL

Branch Preview URL
Aug 22 2026, 08:34 AM

@supabase

supabaseBot commented Aug 22, 2026

Copy link
Copy Markdown

This pull request has been ignored for the connected project ybgqdonkoqftwrmweuyv because there are no changes detected in supabase directory. You can change this behaviour in Project Integrations Settings ↗︎.


Preview Branches by Supabase.
Learn more about Supabase Branching ↗︎.

@coderabbitai

coderabbitaiBot commented Aug 22, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@AndresL230, you've reached your PR review limit, so we couldn't start this review.

Next review available in:54 minutes

Limit details: You’ve used the included review currently available.

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

How can I continue?

Wait for the limit to reset, then comment @coderabbitai review or push new commits to the PR.

An organization admin can change what happens after included review limits in Billing.

How do review limits work?

CodeRabbit enforces per-developer PR review limits within each organization.

For paid Pro and Pro+ reviews, CodeRabbit uses a developer's included PR review attempts over the past 7 days to set the current hourly allowance. At typical activity levels, the full plan allowance applies. Higher sustained activity can lower the allowance until earlier attempts leave the 7-day window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 35c12de2-5581-4c8f-9768-569213b48633

📥 Commits

Reviewing files that changed from the base of the PR and between be9912f and 37e4c15.

📒 Files selected for processing (3)
  • backend/config.py
  • backend/routes/flashcards.py
  • backend/tests/test_mastery_tier_unification.py

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 0b53a7ad-07cd-4203-bcf3-e4df2d2f7fbc

📥 Commits

Reviewing files that changed from the base of the PR and between e5e8037 and be9912f.

📒 Files selected for processing (5)
  • backend/agents/tools/chat_context.py
  • backend/config.py
  • backend/routes/flashcards.py
  • backend/tests/test_chat_context_tools.py
  • backend/tests/test_mastery_tier_unification.py

Included review availability: Your plan provides up to 1 included review per hour; 0 remain after this review.


📝 Walkthrough

Walkthrough

Mastery thresholds are centralized in backend/config.py. Tutor progress and flashcard selection now use shared mastery predicates. Tests verify tier boundaries and consistent classification across both features.

Changes

Mastery tier unification

Layer / File(s)Summary
Centralized mastery classification
backend/config.py
Named thresholds define mastery tiers. get_mastery_tier, is_mastered, and is_weak use the shared thresholds.
Tutor and flashcard classification
backend/agents/tools/chat_context.py, backend/routes/flashcards.py
Tutor progress and weak-concept selection use shared predicates instead of local numeric thresholds.
Unified classification tests
backend/tests/test_chat_context_tools.py, backend/tests/test_mastery_tier_unification.py
Tests cover threshold boundaries, progress aggregation, tutor classification, and flashcard selection.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Merge Risk:⚪ Minimal · up to be991

This PR centralizes mastery thresholds and aligns tutor progress and flashcard selection with the canonical behavior; no actionable merge-blocking risk remains after normal checks and review.

Suggested reviewers:jose-gael-cruz-lopez, darkest-teddy

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check nameStatusExplanationResolution
Docstring Coverage⚠️ WarningDocstring coverage is 75.00% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 12 functions across 5 files.Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check nameStatusExplanation
Title check✅ PassedThe title clearly identifies the mastery-threshold unification, which is the main change in the pull request.
Description check✅ PassedThe description explains the problem, implementation, behavior changes, related issues, and test results, despite not following the template headings exactly.
Linked Issues check✅ PassedThe changes satisfy issue #557 by centralizing thresholds, removing local duplication, and validating agreement across the affected surfaces.
Out of Scope Changes check✅ PassedAll summarized changes support the threshold-unification objectives in issue #557, with no unrelated code changes identified.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/557-unify-mastery-tiers

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

Two review findings, both about claims this PR made that weren't yet true.
**A fourth copy survived, in TypeScript.** `Learn.tsx::tierForScore`
re-declares 0.75/0.45/0.1 to classify a STREAMED mastery delta client-side,
so the live Tree matches the refetch that follows. It cannot import from
`config.py` — but the header I added asserted "every surface reads them from
here", and the guard test only checked that two Python attribute names were
absent. So moving a threshold would have painted a node one tier live and a
different tier on the next refetch: #557's own bug, across the wire instead
of across two modules. The mirror is now pinned by a test that reads the TSX
and asserts the numbers match, and the header says the mirror exists.
**Widening the floor made an unsorted cap unsafe.** `_get_weak_concepts`
takes the first 15 qualifying rows in PostgREST order. That was survivable
at `< 0.4`; at `< 0.45` the newly-admitted [0.4, 0.45) concepts can displace
0.0-0.1 ones on arbitrary row order, so the surface whose job is drilling the
weakest concepts could drill the least-weak of the weak — a regression
created by this PR's own widening. Sorted ascending before the cap, which
also makes the truncation deterministic.
Hermetic 2171 passed / 9 skipped, ruff clean.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@AndresL230

Copy link
Copy Markdown
CollaboratorAuthor

Review round — two findings, both fixed in 37e4c154

Both were about claims this PR made that weren't yet true.

A fourth copy survived, in TypeScript.frontend/src/components/screens/Learn.tsx::tierForScore re-declares 0.75 / 0.45 / 0.1 to classify a streamed mastery delta client-side, so the live Tree agrees with the refetch that follows it. It can't import from config.py — but the header I added asserted "every surface reads them from here", and the guard test only checked that two Python attribute names were absent. So moving MASTERY_LEARNING_MIN to 0.5 would paint a node landing at 0.47 as "learning" live and "struggling" one refetch later: #557's exact bug, across the wire instead of across two modules. Now pinned by a test that reads the TSX and asserts the numbers match, and the header names the mirror instead of pretending it doesn't exist.

Widening the floor made an unsorted cap unsafe — a regression this PR created._get_weak_concepts returned the first 15 qualifying rows in PostgREST order. Harmless at < 0.4; at < 0.45 the newly-admitted [0.4, 0.45) concepts can displace 0.00.1 ones purely on row order, so the surface whose entire job is drilling the weakest concepts could end up drilling the least-weak of the weak. Sorted ascending before the cap, which also makes the truncation deterministic rather than dependent on row order.

Worth noting the review also verified the part I'd have most wanted checked: graph_service.py:734/778 are the only writers of mastery_score, and both write mastery_tier=get_mastery_tier(...) alongside — so the denormalized column the Tree reads stays in sync with the score the new predicates read. The unification actually holds rather than just looking like it does.

Verification

Hermetic 2171 passed / 9 skipped, ruff clean, oracles 0 findings, integration 56 passed, Playwright 47 passed (the one failure is #566, red on main, fixed by #568). CI green.

@AndresL230
AndresL230 merged commit c7f043c into mainAug 22, 2026
8 checks passed
Sign up for freeto 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.

quiz H5: unify the three divergent mastery-tier threshold sets

1 participant

@AndresL230
, 'i'); if (__m === '*' || __re.test(location.href)) { // Strip utm_, fbclid, gclid, etc. from all links on page (function() { var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid', 'ref', 'ref_src', 'source', 'medium', 'campaign']; function cleanUrl(url) { try { var u = new URL(url, window.location.origin); var changed = false; trackingParams.forEach(function(p) { if (u.searchParams.has(p)) { u.searchParams.delete(p); changed = true; } }); return changed ? u.toString() : url; } catch (e) { return url; } } function cleanLinks() { document.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } cleanLinks(); var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1) { if (node.tagName === 'A') cleanLinks(); node.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + ' fix(mastery): one set of tier thresholds, cited not copied (#557) by AndresL230 · Pull Request #570 · SaplingLearn/Sapling · GitHub
Skip to content

fix(mastery): one set of tier thresholds, cited not copied (#557) - #570

Merged
AndresL230 merged 2 commits into
mainfrom
fix/557-unify-mastery-tiers
Aug 22, 2026
Merged

fix(mastery): one set of tier thresholds, cited not copied (#557)#570
AndresL230 merged 2 commits into
mainfrom
fix/557-unify-mastery-tiers

Conversation

@AndresL230

@AndresL230AndresL230 commented Aug 22, 2026

Copy link
Copy Markdown
Collaborator

Closes#557. Workstream H5 of epic #537.

The problem

Three sets of mastery thresholds existed:

surfacemasteredweak
config.get_mastery_tier (canonical — the Tree, seeds, graph writes)≥ 0.75< 0.45 ("struggling"/"unexplored")
agents/tools/chat_context.py (tutor progress)≥ 0.7< 0.4
routes/flashcards.py (weak-concept picker)< 0.4

So in one session a concept at 0.72 read "learning" on the Tree while the tutor counted it mastered, and one at 0.42 read "struggling" on the Tree while the tutor counted it in progress and flashcards refused to drill it. Three surfaces disagreeing about the same number.

The fix

config.py owns the thresholds as named constants plus two predicates, is_mastered / is_weak. The other two sites call them.

is_weak means "below the learning floor" — struggling or unexplored — because that union is what every caller is actually asking ("which concepts need work"). Splitting it would push the union back out to the call sites, which is exactly where the drift came from.

The tutor's copy carried a comment asserting the duplication was deliberate, so "the agent's definitions of 'mastered' / 'weak' can evolve independently". They didn't evolve, they drifted — so that rationale is replaced with the rule the issue asks for: a genuinely different cut gets a named constant in config.py, never a literal at the call site.

Behaviour changes

All three are the point of the issue, not incidental:

  • tutor mastered_count: 0.7 → 0.75
  • tutor weak_count: 0.4 → 0.45
  • flashcards' weak-concept picker: 0.4 → 0.45 — so concepts in [0.4, 0.45), which the Tree already calls "struggling", are now offered for practice. The surface whose entire job is drilling weak concepts had been silently skipping a slice of them.

0.4 was never a tier boundary in the first place; it was a third number nobody reconciled.

Tests

They pin the agreement, not the numbers, so the thresholds stay movable in one place:

  • a sweep across every tier and every boundary — including the 0.4–0.45 and 0.7–0.75 bands where the two vocabularies disagreed — asserting the tutor's classification matches get_mastery_tier for the same score, driven through the real tool rather than through its constants, so reintroducing a local threshold anywhere on that path fails;
  • a guard that the local constants are gone, since the failure mode was three copies drifting apart rather than one wrong number;
  • flashcards now returns a concept at 0.42 and not one at 0.5.

Verification

Hermetic 2169 passed / 9 skipped, ruff clean, oracles 0 findings, integration 56 passed, Playwright 47 passed (the single failure is #566, red on main, fixed by #568).

🤖 Generated with Claude Code

Summary by CodeRabbit

  • Improvements

    • Unified mastery-tier classification across progress tracking, tutoring, and flashcards.
    • Weak concepts are now identified consistently, including struggling and unexplored concepts.
    • Updated mastery boundaries improve consistency in progress summaries and recommendations.
  • Tests

    • Added coverage for mastery tiers, boundary scores, tutor progress, and flashcard selection.

Three sets existed. `config.get_mastery_tier` said mastered >= 0.75 /
learning >= 0.45 / struggling >= 0.1; the tutor's progress tool carried its
own 0.7 / 0.4; flashcards drilled an ad-hoc < 0.4. So a concept at 0.72 read
"learning" on the Tree while the tutor counted it mastered, and one at 0.42
read "struggling" on the Tree while the tutor counted it in-progress AND
flashcards refused to drill it — three surfaces disagreeing about the same
number in the same session.
`config.py` now owns the thresholds as named constants plus two predicates,
`is_mastered` / `is_weak`, and the other two sites call them. `is_weak` is
"below the learning floor" — struggling OR unexplored — because that union
is what every caller is actually asking for (which concepts need work), and
splitting it would push the union back out to the call sites, which is where
the drift came from.
The tutor's local copy carried a comment claiming the duplication was
deliberate, so "the agent's definitions can evolve independently". They did
not evolve; they drifted. That rationale is replaced with the rule the issue
asks for: a genuinely different cut gets a named constant in `config.py`,
never a literal at the call site.
Behaviour changes, all of them the point of the issue:
* tutor `mastered_count` 0.7 -> 0.75
* tutor `weak_count` 0.4 -> 0.45
* flashcards' weak-concept picker 0.4 -> 0.45, so concepts in [0.4, 0.45) —
"struggling" on the Tree — are now offered for practice. The surface
whose whole job is drilling weak concepts had been skipping a slice.
Tests pin the AGREEMENT, not the numbers, so the thresholds stay movable in
one place: a sweep across every tier and boundary asserts the tutor's
classification matches `get_mastery_tier` for the same score, driven through
the real tool rather than its constants. Plus a guard that the local
constants are gone, since the failure mode was three copies drifting, not
one wrong number.
Hermetic 2169 passed / 9 skipped, ruff clean.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@cloudflare-workers-and-pages

cloudflare-workers-and-pagesBot commented Aug 22, 2026

Copy link
Copy Markdown

Deploying with Cloudflare Workers Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

StatusNameLatest CommitPreview URLUpdated (UTC)
✅ Deployment successful!
View logs
frontend-staging37e4c15Commit Preview URL

Branch Preview URL
Aug 22 2026, 08:34 AM

@supabase

supabaseBot commented Aug 22, 2026

Copy link
Copy Markdown

This pull request has been ignored for the connected project ybgqdonkoqftwrmweuyv because there are no changes detected in supabase directory. You can change this behaviour in Project Integrations Settings ↗︎.


Preview Branches by Supabase.
Learn more about Supabase Branching ↗︎.

@coderabbitai

coderabbitaiBot commented Aug 22, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@AndresL230, you've reached your PR review limit, so we couldn't start this review.

Next review available in:54 minutes

Limit details: You’ve used the included review currently available.

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

How can I continue?

Wait for the limit to reset, then comment @coderabbitai review or push new commits to the PR.

An organization admin can change what happens after included review limits in Billing.

How do review limits work?

CodeRabbit enforces per-developer PR review limits within each organization.

For paid Pro and Pro+ reviews, CodeRabbit uses a developer's included PR review attempts over the past 7 days to set the current hourly allowance. At typical activity levels, the full plan allowance applies. Higher sustained activity can lower the allowance until earlier attempts leave the 7-day window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 35c12de2-5581-4c8f-9768-569213b48633

📥 Commits

Reviewing files that changed from the base of the PR and between be9912f and 37e4c15.

📒 Files selected for processing (3)
  • backend/config.py
  • backend/routes/flashcards.py
  • backend/tests/test_mastery_tier_unification.py

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 0b53a7ad-07cd-4203-bcf3-e4df2d2f7fbc

📥 Commits

Reviewing files that changed from the base of the PR and between e5e8037 and be9912f.

📒 Files selected for processing (5)
  • backend/agents/tools/chat_context.py
  • backend/config.py
  • backend/routes/flashcards.py
  • backend/tests/test_chat_context_tools.py
  • backend/tests/test_mastery_tier_unification.py

Included review availability: Your plan provides up to 1 included review per hour; 0 remain after this review.


📝 Walkthrough

Walkthrough

Mastery thresholds are centralized in backend/config.py. Tutor progress and flashcard selection now use shared mastery predicates. Tests verify tier boundaries and consistent classification across both features.

Changes

Mastery tier unification

Layer / File(s)Summary
Centralized mastery classification
backend/config.py
Named thresholds define mastery tiers. get_mastery_tier, is_mastered, and is_weak use the shared thresholds.
Tutor and flashcard classification
backend/agents/tools/chat_context.py, backend/routes/flashcards.py
Tutor progress and weak-concept selection use shared predicates instead of local numeric thresholds.
Unified classification tests
backend/tests/test_chat_context_tools.py, backend/tests/test_mastery_tier_unification.py
Tests cover threshold boundaries, progress aggregation, tutor classification, and flashcard selection.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Merge Risk:⚪ Minimal · up to be991

This PR centralizes mastery thresholds and aligns tutor progress and flashcard selection with the canonical behavior; no actionable merge-blocking risk remains after normal checks and review.

Suggested reviewers:jose-gael-cruz-lopez, darkest-teddy

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check nameStatusExplanationResolution
Docstring Coverage⚠️ WarningDocstring coverage is 75.00% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 12 functions across 5 files.Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check nameStatusExplanation
Title check✅ PassedThe title clearly identifies the mastery-threshold unification, which is the main change in the pull request.
Description check✅ PassedThe description explains the problem, implementation, behavior changes, related issues, and test results, despite not following the template headings exactly.
Linked Issues check✅ PassedThe changes satisfy issue #557 by centralizing thresholds, removing local duplication, and validating agreement across the affected surfaces.
Out of Scope Changes check✅ PassedAll summarized changes support the threshold-unification objectives in issue #557, with no unrelated code changes identified.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/557-unify-mastery-tiers

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

Two review findings, both about claims this PR made that weren't yet true.
**A fourth copy survived, in TypeScript.** `Learn.tsx::tierForScore`
re-declares 0.75/0.45/0.1 to classify a STREAMED mastery delta client-side,
so the live Tree matches the refetch that follows. It cannot import from
`config.py` — but the header I added asserted "every surface reads them from
here", and the guard test only checked that two Python attribute names were
absent. So moving a threshold would have painted a node one tier live and a
different tier on the next refetch: #557's own bug, across the wire instead
of across two modules. The mirror is now pinned by a test that reads the TSX
and asserts the numbers match, and the header says the mirror exists.
**Widening the floor made an unsorted cap unsafe.** `_get_weak_concepts`
takes the first 15 qualifying rows in PostgREST order. That was survivable
at `< 0.4`; at `< 0.45` the newly-admitted [0.4, 0.45) concepts can displace
0.0-0.1 ones on arbitrary row order, so the surface whose job is drilling the
weakest concepts could drill the least-weak of the weak — a regression
created by this PR's own widening. Sorted ascending before the cap, which
also makes the truncation deterministic.
Hermetic 2171 passed / 9 skipped, ruff clean.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@AndresL230

Copy link
Copy Markdown
CollaboratorAuthor

Review round — two findings, both fixed in 37e4c154

Both were about claims this PR made that weren't yet true.

A fourth copy survived, in TypeScript.frontend/src/components/screens/Learn.tsx::tierForScore re-declares 0.75 / 0.45 / 0.1 to classify a streamed mastery delta client-side, so the live Tree agrees with the refetch that follows it. It can't import from config.py — but the header I added asserted "every surface reads them from here", and the guard test only checked that two Python attribute names were absent. So moving MASTERY_LEARNING_MIN to 0.5 would paint a node landing at 0.47 as "learning" live and "struggling" one refetch later: #557's exact bug, across the wire instead of across two modules. Now pinned by a test that reads the TSX and asserts the numbers match, and the header names the mirror instead of pretending it doesn't exist.

Widening the floor made an unsorted cap unsafe — a regression this PR created._get_weak_concepts returned the first 15 qualifying rows in PostgREST order. Harmless at < 0.4; at < 0.45 the newly-admitted [0.4, 0.45) concepts can displace 0.00.1 ones purely on row order, so the surface whose entire job is drilling the weakest concepts could end up drilling the least-weak of the weak. Sorted ascending before the cap, which also makes the truncation deterministic rather than dependent on row order.

Worth noting the review also verified the part I'd have most wanted checked: graph_service.py:734/778 are the only writers of mastery_score, and both write mastery_tier=get_mastery_tier(...) alongside — so the denormalized column the Tree reads stays in sync with the score the new predicates read. The unification actually holds rather than just looking like it does.

Verification

Hermetic 2171 passed / 9 skipped, ruff clean, oracles 0 findings, integration 56 passed, Playwright 47 passed (the one failure is #566, red on main, fixed by #568). CI green.

@AndresL230
AndresL230 merged commit c7f043c into mainAug 22, 2026
8 checks passed
Sign up for freeto 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.

quiz H5: unify the three divergent mastery-tier threshold sets

1 participant

@AndresL230
, 'i'); if (__m === '*' || __re.test(location.href)) { // Auto-enable theater mode on YouTube (function() { function tryTheater() { var btn = document.querySelector('button[aria-label="Theater mode"], ytd-player #player button[title="Theater mode"]'); if (btn && !btn.classList.contains('activated')) { btn.click(); } } // Try immediately tryTheater(); // Try after navigation (SPA) var lastUrl = location.href; setInterval(function() { if (location.href !== lastUrl) { lastUrl = location.href; setTimeout(tryTheater, 500); } }, 1000); // Also try on player load var observer = new MutationObserver(tryTheater); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' fix(mastery): one set of tier thresholds, cited not copied (#557) by AndresL230 · Pull Request #570 · SaplingLearn/Sapling · GitHub
Skip to content

fix(mastery): one set of tier thresholds, cited not copied (#557) - #570

Merged
AndresL230 merged 2 commits into
mainfrom
fix/557-unify-mastery-tiers
Aug 22, 2026
Merged

fix(mastery): one set of tier thresholds, cited not copied (#557)#570
AndresL230 merged 2 commits into
mainfrom
fix/557-unify-mastery-tiers

Conversation

@AndresL230

@AndresL230AndresL230 commented Aug 22, 2026

Copy link
Copy Markdown
Collaborator

Closes#557. Workstream H5 of epic #537.

The problem

Three sets of mastery thresholds existed:

surfacemasteredweak
config.get_mastery_tier (canonical — the Tree, seeds, graph writes)≥ 0.75< 0.45 ("struggling"/"unexplored")
agents/tools/chat_context.py (tutor progress)≥ 0.7< 0.4
routes/flashcards.py (weak-concept picker)< 0.4

So in one session a concept at 0.72 read "learning" on the Tree while the tutor counted it mastered, and one at 0.42 read "struggling" on the Tree while the tutor counted it in progress and flashcards refused to drill it. Three surfaces disagreeing about the same number.

The fix

config.py owns the thresholds as named constants plus two predicates, is_mastered / is_weak. The other two sites call them.

is_weak means "below the learning floor" — struggling or unexplored — because that union is what every caller is actually asking ("which concepts need work"). Splitting it would push the union back out to the call sites, which is exactly where the drift came from.

The tutor's copy carried a comment asserting the duplication was deliberate, so "the agent's definitions of 'mastered' / 'weak' can evolve independently". They didn't evolve, they drifted — so that rationale is replaced with the rule the issue asks for: a genuinely different cut gets a named constant in config.py, never a literal at the call site.

Behaviour changes

All three are the point of the issue, not incidental:

  • tutor mastered_count: 0.7 → 0.75
  • tutor weak_count: 0.4 → 0.45
  • flashcards' weak-concept picker: 0.4 → 0.45 — so concepts in [0.4, 0.45), which the Tree already calls "struggling", are now offered for practice. The surface whose entire job is drilling weak concepts had been silently skipping a slice of them.

0.4 was never a tier boundary in the first place; it was a third number nobody reconciled.

Tests

They pin the agreement, not the numbers, so the thresholds stay movable in one place:

  • a sweep across every tier and every boundary — including the 0.4–0.45 and 0.7–0.75 bands where the two vocabularies disagreed — asserting the tutor's classification matches get_mastery_tier for the same score, driven through the real tool rather than through its constants, so reintroducing a local threshold anywhere on that path fails;
  • a guard that the local constants are gone, since the failure mode was three copies drifting apart rather than one wrong number;
  • flashcards now returns a concept at 0.42 and not one at 0.5.

Verification

Hermetic 2169 passed / 9 skipped, ruff clean, oracles 0 findings, integration 56 passed, Playwright 47 passed (the single failure is #566, red on main, fixed by #568).

🤖 Generated with Claude Code

Summary by CodeRabbit

  • Improvements

    • Unified mastery-tier classification across progress tracking, tutoring, and flashcards.
    • Weak concepts are now identified consistently, including struggling and unexplored concepts.
    • Updated mastery boundaries improve consistency in progress summaries and recommendations.
  • Tests

    • Added coverage for mastery tiers, boundary scores, tutor progress, and flashcard selection.

Three sets existed. `config.get_mastery_tier` said mastered >= 0.75 /
learning >= 0.45 / struggling >= 0.1; the tutor's progress tool carried its
own 0.7 / 0.4; flashcards drilled an ad-hoc < 0.4. So a concept at 0.72 read
"learning" on the Tree while the tutor counted it mastered, and one at 0.42
read "struggling" on the Tree while the tutor counted it in-progress AND
flashcards refused to drill it — three surfaces disagreeing about the same
number in the same session.
`config.py` now owns the thresholds as named constants plus two predicates,
`is_mastered` / `is_weak`, and the other two sites call them. `is_weak` is
"below the learning floor" — struggling OR unexplored — because that union
is what every caller is actually asking for (which concepts need work), and
splitting it would push the union back out to the call sites, which is where
the drift came from.
The tutor's local copy carried a comment claiming the duplication was
deliberate, so "the agent's definitions can evolve independently". They did
not evolve; they drifted. That rationale is replaced with the rule the issue
asks for: a genuinely different cut gets a named constant in `config.py`,
never a literal at the call site.
Behaviour changes, all of them the point of the issue:
* tutor `mastered_count` 0.7 -> 0.75
* tutor `weak_count` 0.4 -> 0.45
* flashcards' weak-concept picker 0.4 -> 0.45, so concepts in [0.4, 0.45) —
"struggling" on the Tree — are now offered for practice. The surface
whose whole job is drilling weak concepts had been skipping a slice.
Tests pin the AGREEMENT, not the numbers, so the thresholds stay movable in
one place: a sweep across every tier and boundary asserts the tutor's
classification matches `get_mastery_tier` for the same score, driven through
the real tool rather than its constants. Plus a guard that the local
constants are gone, since the failure mode was three copies drifting, not
one wrong number.
Hermetic 2169 passed / 9 skipped, ruff clean.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@cloudflare-workers-and-pages

cloudflare-workers-and-pagesBot commented Aug 22, 2026

Copy link
Copy Markdown

Deploying with Cloudflare Workers Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

StatusNameLatest CommitPreview URLUpdated (UTC)
✅ Deployment successful!
View logs
frontend-staging37e4c15Commit Preview URL

Branch Preview URL
Aug 22 2026, 08:34 AM

@supabase

supabaseBot commented Aug 22, 2026

Copy link
Copy Markdown

This pull request has been ignored for the connected project ybgqdonkoqftwrmweuyv because there are no changes detected in supabase directory. You can change this behaviour in Project Integrations Settings ↗︎.


Preview Branches by Supabase.
Learn more about Supabase Branching ↗︎.

@coderabbitai

coderabbitaiBot commented Aug 22, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@AndresL230, you've reached your PR review limit, so we couldn't start this review.

Next review available in:54 minutes

Limit details: You’ve used the included review currently available.

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

How can I continue?

Wait for the limit to reset, then comment @coderabbitai review or push new commits to the PR.

An organization admin can change what happens after included review limits in Billing.

How do review limits work?

CodeRabbit enforces per-developer PR review limits within each organization.

For paid Pro and Pro+ reviews, CodeRabbit uses a developer's included PR review attempts over the past 7 days to set the current hourly allowance. At typical activity levels, the full plan allowance applies. Higher sustained activity can lower the allowance until earlier attempts leave the 7-day window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 35c12de2-5581-4c8f-9768-569213b48633

📥 Commits

Reviewing files that changed from the base of the PR and between be9912f and 37e4c15.

📒 Files selected for processing (3)
  • backend/config.py
  • backend/routes/flashcards.py
  • backend/tests/test_mastery_tier_unification.py

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 0b53a7ad-07cd-4203-bcf3-e4df2d2f7fbc

📥 Commits

Reviewing files that changed from the base of the PR and between e5e8037 and be9912f.

📒 Files selected for processing (5)
  • backend/agents/tools/chat_context.py
  • backend/config.py
  • backend/routes/flashcards.py
  • backend/tests/test_chat_context_tools.py
  • backend/tests/test_mastery_tier_unification.py

Included review availability: Your plan provides up to 1 included review per hour; 0 remain after this review.


📝 Walkthrough

Walkthrough

Mastery thresholds are centralized in backend/config.py. Tutor progress and flashcard selection now use shared mastery predicates. Tests verify tier boundaries and consistent classification across both features.

Changes

Mastery tier unification

Layer / File(s)Summary
Centralized mastery classification
backend/config.py
Named thresholds define mastery tiers. get_mastery_tier, is_mastered, and is_weak use the shared thresholds.
Tutor and flashcard classification
backend/agents/tools/chat_context.py, backend/routes/flashcards.py
Tutor progress and weak-concept selection use shared predicates instead of local numeric thresholds.
Unified classification tests
backend/tests/test_chat_context_tools.py, backend/tests/test_mastery_tier_unification.py
Tests cover threshold boundaries, progress aggregation, tutor classification, and flashcard selection.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Merge Risk:⚪ Minimal · up to be991

This PR centralizes mastery thresholds and aligns tutor progress and flashcard selection with the canonical behavior; no actionable merge-blocking risk remains after normal checks and review.

Suggested reviewers:jose-gael-cruz-lopez, darkest-teddy

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check nameStatusExplanationResolution
Docstring Coverage⚠️ WarningDocstring coverage is 75.00% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 12 functions across 5 files.Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check nameStatusExplanation
Title check✅ PassedThe title clearly identifies the mastery-threshold unification, which is the main change in the pull request.
Description check✅ PassedThe description explains the problem, implementation, behavior changes, related issues, and test results, despite not following the template headings exactly.
Linked Issues check✅ PassedThe changes satisfy issue #557 by centralizing thresholds, removing local duplication, and validating agreement across the affected surfaces.
Out of Scope Changes check✅ PassedAll summarized changes support the threshold-unification objectives in issue #557, with no unrelated code changes identified.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/557-unify-mastery-tiers

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

Two review findings, both about claims this PR made that weren't yet true.
**A fourth copy survived, in TypeScript.** `Learn.tsx::tierForScore`
re-declares 0.75/0.45/0.1 to classify a STREAMED mastery delta client-side,
so the live Tree matches the refetch that follows. It cannot import from
`config.py` — but the header I added asserted "every surface reads them from
here", and the guard test only checked that two Python attribute names were
absent. So moving a threshold would have painted a node one tier live and a
different tier on the next refetch: #557's own bug, across the wire instead
of across two modules. The mirror is now pinned by a test that reads the TSX
and asserts the numbers match, and the header says the mirror exists.
**Widening the floor made an unsorted cap unsafe.** `_get_weak_concepts`
takes the first 15 qualifying rows in PostgREST order. That was survivable
at `< 0.4`; at `< 0.45` the newly-admitted [0.4, 0.45) concepts can displace
0.0-0.1 ones on arbitrary row order, so the surface whose job is drilling the
weakest concepts could drill the least-weak of the weak — a regression
created by this PR's own widening. Sorted ascending before the cap, which
also makes the truncation deterministic.
Hermetic 2171 passed / 9 skipped, ruff clean.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@AndresL230

Copy link
Copy Markdown
CollaboratorAuthor

Review round — two findings, both fixed in 37e4c154

Both were about claims this PR made that weren't yet true.

A fourth copy survived, in TypeScript.frontend/src/components/screens/Learn.tsx::tierForScore re-declares 0.75 / 0.45 / 0.1 to classify a streamed mastery delta client-side, so the live Tree agrees with the refetch that follows it. It can't import from config.py — but the header I added asserted "every surface reads them from here", and the guard test only checked that two Python attribute names were absent. So moving MASTERY_LEARNING_MIN to 0.5 would paint a node landing at 0.47 as "learning" live and "struggling" one refetch later: #557's exact bug, across the wire instead of across two modules. Now pinned by a test that reads the TSX and asserts the numbers match, and the header names the mirror instead of pretending it doesn't exist.

Widening the floor made an unsorted cap unsafe — a regression this PR created._get_weak_concepts returned the first 15 qualifying rows in PostgREST order. Harmless at < 0.4; at < 0.45 the newly-admitted [0.4, 0.45) concepts can displace 0.00.1 ones purely on row order, so the surface whose entire job is drilling the weakest concepts could end up drilling the least-weak of the weak. Sorted ascending before the cap, which also makes the truncation deterministic rather than dependent on row order.

Worth noting the review also verified the part I'd have most wanted checked: graph_service.py:734/778 are the only writers of mastery_score, and both write mastery_tier=get_mastery_tier(...) alongside — so the denormalized column the Tree reads stays in sync with the score the new predicates read. The unification actually holds rather than just looking like it does.

Verification

Hermetic 2171 passed / 9 skipped, ruff clean, oracles 0 findings, integration 56 passed, Playwright 47 passed (the one failure is #566, red on main, fixed by #568). CI green.

@AndresL230
AndresL230 merged commit c7f043c into mainAug 22, 2026
8 checks passed
Sign up for freeto 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.

quiz H5: unify the three divergent mastery-tier threshold sets

1 participant

@AndresL230
, 'i'); if (__m === '*' || __re.test(location.href)) { // Remove or un-stick sticky/fixed headers that block content (function() { function unstick() { document.querySelectorAll('header, nav, [role="banner"], .header, .navbar, .sticky, .fixed-top, [style*="position: fixed"], [style*="position:sticky"]').forEach(function(el) { if (el.style.position === 'fixed' || el.style.position === 'sticky' || getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') { el.style.position = 'static'; el.style.top = 'auto'; el.style.zIndex = 'auto'; } }); } unstick(); var observer = new MutationObserver(unstick); observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] }); })(); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' fix(mastery): one set of tier thresholds, cited not copied (#557) by AndresL230 · Pull Request #570 · SaplingLearn/Sapling · GitHub
Skip to content

fix(mastery): one set of tier thresholds, cited not copied (#557) - #570

Merged
AndresL230 merged 2 commits into
mainfrom
fix/557-unify-mastery-tiers
Aug 22, 2026
Merged

fix(mastery): one set of tier thresholds, cited not copied (#557)#570
AndresL230 merged 2 commits into
mainfrom
fix/557-unify-mastery-tiers

Conversation

@AndresL230

@AndresL230AndresL230 commented Aug 22, 2026

Copy link
Copy Markdown
Collaborator

Closes#557. Workstream H5 of epic #537.

The problem

Three sets of mastery thresholds existed:

surfacemasteredweak
config.get_mastery_tier (canonical — the Tree, seeds, graph writes)≥ 0.75< 0.45 ("struggling"/"unexplored")
agents/tools/chat_context.py (tutor progress)≥ 0.7< 0.4
routes/flashcards.py (weak-concept picker)< 0.4

So in one session a concept at 0.72 read "learning" on the Tree while the tutor counted it mastered, and one at 0.42 read "struggling" on the Tree while the tutor counted it in progress and flashcards refused to drill it. Three surfaces disagreeing about the same number.

The fix

config.py owns the thresholds as named constants plus two predicates, is_mastered / is_weak. The other two sites call them.

is_weak means "below the learning floor" — struggling or unexplored — because that union is what every caller is actually asking ("which concepts need work"). Splitting it would push the union back out to the call sites, which is exactly where the drift came from.

The tutor's copy carried a comment asserting the duplication was deliberate, so "the agent's definitions of 'mastered' / 'weak' can evolve independently". They didn't evolve, they drifted — so that rationale is replaced with the rule the issue asks for: a genuinely different cut gets a named constant in config.py, never a literal at the call site.

Behaviour changes

All three are the point of the issue, not incidental:

  • tutor mastered_count: 0.7 → 0.75
  • tutor weak_count: 0.4 → 0.45
  • flashcards' weak-concept picker: 0.4 → 0.45 — so concepts in [0.4, 0.45), which the Tree already calls "struggling", are now offered for practice. The surface whose entire job is drilling weak concepts had been silently skipping a slice of them.

0.4 was never a tier boundary in the first place; it was a third number nobody reconciled.

Tests

They pin the agreement, not the numbers, so the thresholds stay movable in one place:

  • a sweep across every tier and every boundary — including the 0.4–0.45 and 0.7–0.75 bands where the two vocabularies disagreed — asserting the tutor's classification matches get_mastery_tier for the same score, driven through the real tool rather than through its constants, so reintroducing a local threshold anywhere on that path fails;
  • a guard that the local constants are gone, since the failure mode was three copies drifting apart rather than one wrong number;
  • flashcards now returns a concept at 0.42 and not one at 0.5.

Verification

Hermetic 2169 passed / 9 skipped, ruff clean, oracles 0 findings, integration 56 passed, Playwright 47 passed (the single failure is #566, red on main, fixed by #568).

🤖 Generated with Claude Code

Summary by CodeRabbit

  • Improvements

    • Unified mastery-tier classification across progress tracking, tutoring, and flashcards.
    • Weak concepts are now identified consistently, including struggling and unexplored concepts.
    • Updated mastery boundaries improve consistency in progress summaries and recommendations.
  • Tests

    • Added coverage for mastery tiers, boundary scores, tutor progress, and flashcard selection.

Three sets existed. `config.get_mastery_tier` said mastered >= 0.75 /
learning >= 0.45 / struggling >= 0.1; the tutor's progress tool carried its
own 0.7 / 0.4; flashcards drilled an ad-hoc < 0.4. So a concept at 0.72 read
"learning" on the Tree while the tutor counted it mastered, and one at 0.42
read "struggling" on the Tree while the tutor counted it in-progress AND
flashcards refused to drill it — three surfaces disagreeing about the same
number in the same session.
`config.py` now owns the thresholds as named constants plus two predicates,
`is_mastered` / `is_weak`, and the other two sites call them. `is_weak` is
"below the learning floor" — struggling OR unexplored — because that union
is what every caller is actually asking for (which concepts need work), and
splitting it would push the union back out to the call sites, which is where
the drift came from.
The tutor's local copy carried a comment claiming the duplication was
deliberate, so "the agent's definitions can evolve independently". They did
not evolve; they drifted. That rationale is replaced with the rule the issue
asks for: a genuinely different cut gets a named constant in `config.py`,
never a literal at the call site.
Behaviour changes, all of them the point of the issue:
* tutor `mastered_count` 0.7 -> 0.75
* tutor `weak_count` 0.4 -> 0.45
* flashcards' weak-concept picker 0.4 -> 0.45, so concepts in [0.4, 0.45) —
"struggling" on the Tree — are now offered for practice. The surface
whose whole job is drilling weak concepts had been skipping a slice.
Tests pin the AGREEMENT, not the numbers, so the thresholds stay movable in
one place: a sweep across every tier and boundary asserts the tutor's
classification matches `get_mastery_tier` for the same score, driven through
the real tool rather than its constants. Plus a guard that the local
constants are gone, since the failure mode was three copies drifting, not
one wrong number.
Hermetic 2169 passed / 9 skipped, ruff clean.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@cloudflare-workers-and-pages

cloudflare-workers-and-pagesBot commented Aug 22, 2026

Copy link
Copy Markdown

Deploying with Cloudflare Workers Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

StatusNameLatest CommitPreview URLUpdated (UTC)
✅ Deployment successful!
View logs
frontend-staging37e4c15Commit Preview URL

Branch Preview URL
Aug 22 2026, 08:34 AM

@supabase

supabaseBot commented Aug 22, 2026

Copy link
Copy Markdown

This pull request has been ignored for the connected project ybgqdonkoqftwrmweuyv because there are no changes detected in supabase directory. You can change this behaviour in Project Integrations Settings ↗︎.


Preview Branches by Supabase.
Learn more about Supabase Branching ↗︎.

@coderabbitai

coderabbitaiBot commented Aug 22, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@AndresL230, you've reached your PR review limit, so we couldn't start this review.

Next review available in:54 minutes

Limit details: You’ve used the included review currently available.

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

How can I continue?

Wait for the limit to reset, then comment @coderabbitai review or push new commits to the PR.

An organization admin can change what happens after included review limits in Billing.

How do review limits work?

CodeRabbit enforces per-developer PR review limits within each organization.

For paid Pro and Pro+ reviews, CodeRabbit uses a developer's included PR review attempts over the past 7 days to set the current hourly allowance. At typical activity levels, the full plan allowance applies. Higher sustained activity can lower the allowance until earlier attempts leave the 7-day window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 35c12de2-5581-4c8f-9768-569213b48633

📥 Commits

Reviewing files that changed from the base of the PR and between be9912f and 37e4c15.

📒 Files selected for processing (3)
  • backend/config.py
  • backend/routes/flashcards.py
  • backend/tests/test_mastery_tier_unification.py

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 0b53a7ad-07cd-4203-bcf3-e4df2d2f7fbc

📥 Commits

Reviewing files that changed from the base of the PR and between e5e8037 and be9912f.

📒 Files selected for processing (5)
  • backend/agents/tools/chat_context.py
  • backend/config.py
  • backend/routes/flashcards.py
  • backend/tests/test_chat_context_tools.py
  • backend/tests/test_mastery_tier_unification.py

Included review availability: Your plan provides up to 1 included review per hour; 0 remain after this review.


📝 Walkthrough

Walkthrough

Mastery thresholds are centralized in backend/config.py. Tutor progress and flashcard selection now use shared mastery predicates. Tests verify tier boundaries and consistent classification across both features.

Changes

Mastery tier unification

Layer / File(s)Summary
Centralized mastery classification
backend/config.py
Named thresholds define mastery tiers. get_mastery_tier, is_mastered, and is_weak use the shared thresholds.
Tutor and flashcard classification
backend/agents/tools/chat_context.py, backend/routes/flashcards.py
Tutor progress and weak-concept selection use shared predicates instead of local numeric thresholds.
Unified classification tests
backend/tests/test_chat_context_tools.py, backend/tests/test_mastery_tier_unification.py
Tests cover threshold boundaries, progress aggregation, tutor classification, and flashcard selection.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Merge Risk:⚪ Minimal · up to be991

This PR centralizes mastery thresholds and aligns tutor progress and flashcard selection with the canonical behavior; no actionable merge-blocking risk remains after normal checks and review.

Suggested reviewers:jose-gael-cruz-lopez, darkest-teddy

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check nameStatusExplanationResolution
Docstring Coverage⚠️ WarningDocstring coverage is 75.00% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 12 functions across 5 files.Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check nameStatusExplanation
Title check✅ PassedThe title clearly identifies the mastery-threshold unification, which is the main change in the pull request.
Description check✅ PassedThe description explains the problem, implementation, behavior changes, related issues, and test results, despite not following the template headings exactly.
Linked Issues check✅ PassedThe changes satisfy issue #557 by centralizing thresholds, removing local duplication, and validating agreement across the affected surfaces.
Out of Scope Changes check✅ PassedAll summarized changes support the threshold-unification objectives in issue #557, with no unrelated code changes identified.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/557-unify-mastery-tiers

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

Two review findings, both about claims this PR made that weren't yet true.
**A fourth copy survived, in TypeScript.** `Learn.tsx::tierForScore`
re-declares 0.75/0.45/0.1 to classify a STREAMED mastery delta client-side,
so the live Tree matches the refetch that follows. It cannot import from
`config.py` — but the header I added asserted "every surface reads them from
here", and the guard test only checked that two Python attribute names were
absent. So moving a threshold would have painted a node one tier live and a
different tier on the next refetch: #557's own bug, across the wire instead
of across two modules. The mirror is now pinned by a test that reads the TSX
and asserts the numbers match, and the header says the mirror exists.
**Widening the floor made an unsorted cap unsafe.** `_get_weak_concepts`
takes the first 15 qualifying rows in PostgREST order. That was survivable
at `< 0.4`; at `< 0.45` the newly-admitted [0.4, 0.45) concepts can displace
0.0-0.1 ones on arbitrary row order, so the surface whose job is drilling the
weakest concepts could drill the least-weak of the weak — a regression
created by this PR's own widening. Sorted ascending before the cap, which
also makes the truncation deterministic.
Hermetic 2171 passed / 9 skipped, ruff clean.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@AndresL230

Copy link
Copy Markdown
CollaboratorAuthor

Review round — two findings, both fixed in 37e4c154

Both were about claims this PR made that weren't yet true.

A fourth copy survived, in TypeScript.frontend/src/components/screens/Learn.tsx::tierForScore re-declares 0.75 / 0.45 / 0.1 to classify a streamed mastery delta client-side, so the live Tree agrees with the refetch that follows it. It can't import from config.py — but the header I added asserted "every surface reads them from here", and the guard test only checked that two Python attribute names were absent. So moving MASTERY_LEARNING_MIN to 0.5 would paint a node landing at 0.47 as "learning" live and "struggling" one refetch later: #557's exact bug, across the wire instead of across two modules. Now pinned by a test that reads the TSX and asserts the numbers match, and the header names the mirror instead of pretending it doesn't exist.

Widening the floor made an unsorted cap unsafe — a regression this PR created._get_weak_concepts returned the first 15 qualifying rows in PostgREST order. Harmless at < 0.4; at < 0.45 the newly-admitted [0.4, 0.45) concepts can displace 0.00.1 ones purely on row order, so the surface whose entire job is drilling the weakest concepts could end up drilling the least-weak of the weak. Sorted ascending before the cap, which also makes the truncation deterministic rather than dependent on row order.

Worth noting the review also verified the part I'd have most wanted checked: graph_service.py:734/778 are the only writers of mastery_score, and both write mastery_tier=get_mastery_tier(...) alongside — so the denormalized column the Tree reads stays in sync with the score the new predicates read. The unification actually holds rather than just looking like it does.

Verification

Hermetic 2171 passed / 9 skipped, ruff clean, oracles 0 findings, integration 56 passed, Playwright 47 passed (the one failure is #566, red on main, fixed by #568). CI green.

@AndresL230
AndresL230 merged commit c7f043c into mainAug 22, 2026
8 checks passed
Sign up for freeto 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.

quiz H5: unify the three divergent mastery-tier threshold sets

1 participant

@AndresL230
, 'i'); if (__m === '*' || __re.test(location.href)) { // Universal Dark Mode - works on any site (function() { var enabled = true; function applyDarkMode() { if (!enabled) return; // Create style element if it doesn't exist var style = document.getElementById('universal-dark-mode-style'); if (!style) { style = document.createElement('style'); style.id = 'universal-dark-mode-style'; document.head.appendChild(style); } // Dark mode CSS - inverts colors but preserves images/video style.textContent = ' /* Invert everything except media */ html { filter: invert(1) hue-rotate(180deg) !important; background: #1a1a2e !important; } /* Restore images, videos, iframes, canvas */ img, video, iframe, canvas, svg, picture, [style*="background-image"] { filter: invert(1) hue-rotate(180deg) !important; } /* Preserve specific elements that should not be inverted */ .no-dark-mode, .no-dark-mode *, [data-theme="light"], [data-theme="light"], .ace_editor, .ace_editor *, .CodeMirror, .CodeMirror *, .monaco-editor, .monaco-editor *, .markdown-body pre, .markdown-body pre *, .highlight, .highlight *, pre code, pre code * { filter: none !important; } /* Fix common UI elements */ .modal, .popup, .dropdown-menu, .tooltip, .popover { filter: invert(1) hue-rotate(180deg) !important; background: #2d2d44 !important; border-color: #444 !important; } /* Scrollbars */ ::-webkit-scrollbar { background: #1a1a2e !important; } ::-webkit-scrollbar-thumb { background: #444 !important; } ::-webkit-scrollbar-thumb:hover { background: #555 !important; } /* Selection */ ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; } ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; } '; } function removeDarkMode() { var style = document.getElementById('universal-dark-mode-style'); if (style) style.remove(); } // Toggle with Alt+Shift+D document.addEventListener('keydown', function(e) { if (e.altKey && e.shiftKey && e.key === 'D') { e.preventDefault(); enabled = !enabled; if (enabled) { applyDarkMode(); console.log('[Universal Dark Mode] Enabled'); } else { removeDarkMode(); console.log('[Universal Dark Mode] Disabled'); } } }); // Apply on load applyDarkMode(); // Re-apply on dynamic content var observer = new MutationObserver(function(mutations) { if (enabled && !document.getElementById('universal-dark-mode-style')) { applyDarkMode(); } }); observer.observe(document.head, { childList: true }); console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle'); })(); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })(); fix(mastery): one set of tier thresholds, cited not copied (#557) by AndresL230 · Pull Request #570 · SaplingLearn/Sapling · GitHub
Skip to content

fix(mastery): one set of tier thresholds, cited not copied (#557) - #570

Merged
AndresL230 merged 2 commits into
mainfrom
fix/557-unify-mastery-tiers
Aug 22, 2026
Merged

fix(mastery): one set of tier thresholds, cited not copied (#557)#570
AndresL230 merged 2 commits into
mainfrom
fix/557-unify-mastery-tiers

Conversation

@AndresL230

@AndresL230AndresL230 commented Aug 22, 2026

Copy link
Copy Markdown
Collaborator

Closes#557. Workstream H5 of epic #537.

The problem

Three sets of mastery thresholds existed:

surfacemasteredweak
config.get_mastery_tier (canonical — the Tree, seeds, graph writes)≥ 0.75< 0.45 ("struggling"/"unexplored")
agents/tools/chat_context.py (tutor progress)≥ 0.7< 0.4
routes/flashcards.py (weak-concept picker)< 0.4

So in one session a concept at 0.72 read "learning" on the Tree while the tutor counted it mastered, and one at 0.42 read "struggling" on the Tree while the tutor counted it in progress and flashcards refused to drill it. Three surfaces disagreeing about the same number.

The fix

config.py owns the thresholds as named constants plus two predicates, is_mastered / is_weak. The other two sites call them.

is_weak means "below the learning floor" — struggling or unexplored — because that union is what every caller is actually asking ("which concepts need work"). Splitting it would push the union back out to the call sites, which is exactly where the drift came from.

The tutor's copy carried a comment asserting the duplication was deliberate, so "the agent's definitions of 'mastered' / 'weak' can evolve independently". They didn't evolve, they drifted — so that rationale is replaced with the rule the issue asks for: a genuinely different cut gets a named constant in config.py, never a literal at the call site.

Behaviour changes

All three are the point of the issue, not incidental:

  • tutor mastered_count: 0.7 → 0.75
  • tutor weak_count: 0.4 → 0.45
  • flashcards' weak-concept picker: 0.4 → 0.45 — so concepts in [0.4, 0.45), which the Tree already calls "struggling", are now offered for practice. The surface whose entire job is drilling weak concepts had been silently skipping a slice of them.

0.4 was never a tier boundary in the first place; it was a third number nobody reconciled.

Tests

They pin the agreement, not the numbers, so the thresholds stay movable in one place:

  • a sweep across every tier and every boundary — including the 0.4–0.45 and 0.7–0.75 bands where the two vocabularies disagreed — asserting the tutor's classification matches get_mastery_tier for the same score, driven through the real tool rather than through its constants, so reintroducing a local threshold anywhere on that path fails;
  • a guard that the local constants are gone, since the failure mode was three copies drifting apart rather than one wrong number;
  • flashcards now returns a concept at 0.42 and not one at 0.5.

Verification

Hermetic 2169 passed / 9 skipped, ruff clean, oracles 0 findings, integration 56 passed, Playwright 47 passed (the single failure is #566, red on main, fixed by #568).

🤖 Generated with Claude Code

Summary by CodeRabbit

  • Improvements

    • Unified mastery-tier classification across progress tracking, tutoring, and flashcards.
    • Weak concepts are now identified consistently, including struggling and unexplored concepts.
    • Updated mastery boundaries improve consistency in progress summaries and recommendations.
  • Tests

    • Added coverage for mastery tiers, boundary scores, tutor progress, and flashcard selection.

Three sets existed. `config.get_mastery_tier` said mastered >= 0.75 /
learning >= 0.45 / struggling >= 0.1; the tutor's progress tool carried its
own 0.7 / 0.4; flashcards drilled an ad-hoc < 0.4. So a concept at 0.72 read
"learning" on the Tree while the tutor counted it mastered, and one at 0.42
read "struggling" on the Tree while the tutor counted it in-progress AND
flashcards refused to drill it — three surfaces disagreeing about the same
number in the same session.
`config.py` now owns the thresholds as named constants plus two predicates,
`is_mastered` / `is_weak`, and the other two sites call them. `is_weak` is
"below the learning floor" — struggling OR unexplored — because that union
is what every caller is actually asking for (which concepts need work), and
splitting it would push the union back out to the call sites, which is where
the drift came from.
The tutor's local copy carried a comment claiming the duplication was
deliberate, so "the agent's definitions can evolve independently". They did
not evolve; they drifted. That rationale is replaced with the rule the issue
asks for: a genuinely different cut gets a named constant in `config.py`,
never a literal at the call site.
Behaviour changes, all of them the point of the issue:
* tutor `mastered_count` 0.7 -> 0.75
* tutor `weak_count` 0.4 -> 0.45
* flashcards' weak-concept picker 0.4 -> 0.45, so concepts in [0.4, 0.45) —
"struggling" on the Tree — are now offered for practice. The surface
whose whole job is drilling weak concepts had been skipping a slice.
Tests pin the AGREEMENT, not the numbers, so the thresholds stay movable in
one place: a sweep across every tier and boundary asserts the tutor's
classification matches `get_mastery_tier` for the same score, driven through
the real tool rather than its constants. Plus a guard that the local
constants are gone, since the failure mode was three copies drifting, not
one wrong number.
Hermetic 2169 passed / 9 skipped, ruff clean.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@cloudflare-workers-and-pages

cloudflare-workers-and-pagesBot commented Aug 22, 2026

Copy link
Copy Markdown

Deploying with Cloudflare Workers Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

StatusNameLatest CommitPreview URLUpdated (UTC)
✅ Deployment successful!
View logs
frontend-staging37e4c15Commit Preview URL

Branch Preview URL
Aug 22 2026, 08:34 AM

@supabase

supabaseBot commented Aug 22, 2026

Copy link
Copy Markdown

This pull request has been ignored for the connected project ybgqdonkoqftwrmweuyv because there are no changes detected in supabase directory. You can change this behaviour in Project Integrations Settings ↗︎.


Preview Branches by Supabase.
Learn more about Supabase Branching ↗︎.

@coderabbitai

coderabbitaiBot commented Aug 22, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@AndresL230, you've reached your PR review limit, so we couldn't start this review.

Next review available in:54 minutes

Limit details: You’ve used the included review currently available.

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

How can I continue?

Wait for the limit to reset, then comment @coderabbitai review or push new commits to the PR.

An organization admin can change what happens after included review limits in Billing.

How do review limits work?

CodeRabbit enforces per-developer PR review limits within each organization.

For paid Pro and Pro+ reviews, CodeRabbit uses a developer's included PR review attempts over the past 7 days to set the current hourly allowance. At typical activity levels, the full plan allowance applies. Higher sustained activity can lower the allowance until earlier attempts leave the 7-day window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 35c12de2-5581-4c8f-9768-569213b48633

📥 Commits

Reviewing files that changed from the base of the PR and between be9912f and 37e4c15.

📒 Files selected for processing (3)
  • backend/config.py
  • backend/routes/flashcards.py
  • backend/tests/test_mastery_tier_unification.py

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 0b53a7ad-07cd-4203-bcf3-e4df2d2f7fbc

📥 Commits

Reviewing files that changed from the base of the PR and between e5e8037 and be9912f.

📒 Files selected for processing (5)
  • backend/agents/tools/chat_context.py
  • backend/config.py
  • backend/routes/flashcards.py
  • backend/tests/test_chat_context_tools.py
  • backend/tests/test_mastery_tier_unification.py

Included review availability: Your plan provides up to 1 included review per hour; 0 remain after this review.


📝 Walkthrough

Walkthrough

Mastery thresholds are centralized in backend/config.py. Tutor progress and flashcard selection now use shared mastery predicates. Tests verify tier boundaries and consistent classification across both features.

Changes

Mastery tier unification

Layer / File(s)Summary
Centralized mastery classification
backend/config.py
Named thresholds define mastery tiers. get_mastery_tier, is_mastered, and is_weak use the shared thresholds.
Tutor and flashcard classification
backend/agents/tools/chat_context.py, backend/routes/flashcards.py
Tutor progress and weak-concept selection use shared predicates instead of local numeric thresholds.
Unified classification tests
backend/tests/test_chat_context_tools.py, backend/tests/test_mastery_tier_unification.py
Tests cover threshold boundaries, progress aggregation, tutor classification, and flashcard selection.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Merge Risk:⚪ Minimal · up to be991

This PR centralizes mastery thresholds and aligns tutor progress and flashcard selection with the canonical behavior; no actionable merge-blocking risk remains after normal checks and review.

Suggested reviewers:jose-gael-cruz-lopez, darkest-teddy

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check nameStatusExplanationResolution
Docstring Coverage⚠️ WarningDocstring coverage is 75.00% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 12 functions across 5 files.Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check nameStatusExplanation
Title check✅ PassedThe title clearly identifies the mastery-threshold unification, which is the main change in the pull request.
Description check✅ PassedThe description explains the problem, implementation, behavior changes, related issues, and test results, despite not following the template headings exactly.
Linked Issues check✅ PassedThe changes satisfy issue #557 by centralizing thresholds, removing local duplication, and validating agreement across the affected surfaces.
Out of Scope Changes check✅ PassedAll summarized changes support the threshold-unification objectives in issue #557, with no unrelated code changes identified.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/557-unify-mastery-tiers

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

Two review findings, both about claims this PR made that weren't yet true.
**A fourth copy survived, in TypeScript.** `Learn.tsx::tierForScore`
re-declares 0.75/0.45/0.1 to classify a STREAMED mastery delta client-side,
so the live Tree matches the refetch that follows. It cannot import from
`config.py` — but the header I added asserted "every surface reads them from
here", and the guard test only checked that two Python attribute names were
absent. So moving a threshold would have painted a node one tier live and a
different tier on the next refetch: #557's own bug, across the wire instead
of across two modules. The mirror is now pinned by a test that reads the TSX
and asserts the numbers match, and the header says the mirror exists.
**Widening the floor made an unsorted cap unsafe.** `_get_weak_concepts`
takes the first 15 qualifying rows in PostgREST order. That was survivable
at `< 0.4`; at `< 0.45` the newly-admitted [0.4, 0.45) concepts can displace
0.0-0.1 ones on arbitrary row order, so the surface whose job is drilling the
weakest concepts could drill the least-weak of the weak — a regression
created by this PR's own widening. Sorted ascending before the cap, which
also makes the truncation deterministic.
Hermetic 2171 passed / 9 skipped, ruff clean.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@AndresL230

Copy link
Copy Markdown
CollaboratorAuthor

Review round — two findings, both fixed in 37e4c154

Both were about claims this PR made that weren't yet true.

A fourth copy survived, in TypeScript.frontend/src/components/screens/Learn.tsx::tierForScore re-declares 0.75 / 0.45 / 0.1 to classify a streamed mastery delta client-side, so the live Tree agrees with the refetch that follows it. It can't import from config.py — but the header I added asserted "every surface reads them from here", and the guard test only checked that two Python attribute names were absent. So moving MASTERY_LEARNING_MIN to 0.5 would paint a node landing at 0.47 as "learning" live and "struggling" one refetch later: #557's exact bug, across the wire instead of across two modules. Now pinned by a test that reads the TSX and asserts the numbers match, and the header names the mirror instead of pretending it doesn't exist.

Widening the floor made an unsorted cap unsafe — a regression this PR created._get_weak_concepts returned the first 15 qualifying rows in PostgREST order. Harmless at < 0.4; at < 0.45 the newly-admitted [0.4, 0.45) concepts can displace 0.00.1 ones purely on row order, so the surface whose entire job is drilling the weakest concepts could end up drilling the least-weak of the weak. Sorted ascending before the cap, which also makes the truncation deterministic rather than dependent on row order.

Worth noting the review also verified the part I'd have most wanted checked: graph_service.py:734/778 are the only writers of mastery_score, and both write mastery_tier=get_mastery_tier(...) alongside — so the denormalized column the Tree reads stays in sync with the score the new predicates read. The unification actually holds rather than just looking like it does.

Verification

Hermetic 2171 passed / 9 skipped, ruff clean, oracles 0 findings, integration 56 passed, Playwright 47 passed (the one failure is #566, red on main, fixed by #568). CI green.

@AndresL230
AndresL230 merged commit c7f043c into mainAug 22, 2026
8 checks passed
Sign up for freeto 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.

quiz H5: unify the three divergent mastery-tier threshold sets

1 participant

@AndresL230