Repository navigation
feat: roll up learner competency statuses above a graded criterion - #870
alezconsultant wants to merge 3 commits into
Conversation
|
Thanks for the pull request, @alezconsultant! This repository is currently maintained by Once you've gone through the following steps feel free to tag them in a comment and let them know that your changes are ready for engineering review. 🔘 Get product approvalIf you haven't already, check this list to see if your contribution needs to go through the product review process.
🔘 Provide contextTo help your reviewers and other members of the community understand the purpose and larger context of your changes, feel free to add as much of the following information to the PR description as you can:
🔘 Get a green buildIf one or more checks are failing, continue working on your changes until this is no longer the case and your build turns green. DetailsWhere can I find more information?If you'd like to get more details on all aspects of the review process for open source pull requests (OSPRs), check out the following resources: When can I expect my changes to be merged?Our goal is to get community contributions seen and reviewed as efficiently as possible. However, the amount of time that it takes to review and merge a PR can vary significantly based on factors such as:
💡 As a result it may take up to several weeks or months to complete a review and merge your PR. |
tbain
left a comment
There was a problem hiding this comment.
Human questions (Some of these are from Mary):
- (Comment cleanup - please clarify) For line 2 of rollup.py, what is meant by "changed criteria"?
- (Question) For line 50 of rollup.py, is it technically possible to get to this line? Is this a requirement of the AC? Probably okay to leave as a safe default for some unforeseen logic branch, but curious if this can even be gotten to, and if we can clean this up.
- (Please add comment) It might be good to add a comment above line 51 of rollup.py indicating that this is covering the case of LogicOperator.OR (or use an "else").
- (Comment cleanup - please clarify) I don't understand the comment on lines 54-65 in rollup.py.
- (Question, just to ensure this) Does DEMONSTRATED and partially_ATTEMPTED have a numerical value where max makes sense to use (e.g. line 67 of rollup.py)?
- (Question) rollup.py:154 - is it necessary to go through the entire array if we find a value that matches the "best" status, or can it break early if we get DEMONSTRATED for a given tag?
- (Comment cleanup - please clarify) rollyup.py:187 "# A lineage ends with a tab..." comment - what does that mean? we're not testing for a \t or anything? Is this comment out of date or superfluous?
- (Nitpick) test_tag_rollup.py:196 - the assert statement for
test_a_sub_competency_with_criteria_only_further_down_still_participates()seems overly concise, it makes the logic hard to understand at a glance. Could this just be made into 3 separate assert lines instead? - (Nitpick) test_tag_rollup.py:232
test_the_deepest_allowed_taxonomy_is_walked_to_the_top()has the same overly concise one-liner assert; could we make this 6 separate assert lines instead to make it easier to understand at a glance? - (Nitpick) test_tag_rollup.py:297,
test_competencies_graded_at_different_depths_converge_on_the_shared_ancestor()same overly concise format for assertions
Additionally, Codex raised these two bugs:
- Bug.
[rollup.py:41-55](https://github.com/openedx/openedx-core/blob/c1b178827389f64f9235d12e9761ec2e5145095b/src/openedx_learning/applets/cbe/rollup.py#L41-L55),[rollup.py:84-102](https://github.com/openedx/openedx-core/blob/c1b178827389f64f9235d12e9761ec2e5145095b/src/openedx_learning/applets/cbe/rollup.py#L84-L102): the implementation ignores each child’sorderingand always reads/materializes every sibling status. This does not implement the ADR/ticket’s required ordered short-circuit evaluation. For large groups, an already-determinedAND/ORresult still performs a full sibling scan. Preserve ordering and short-circuit status reads, with a regression test proving later siblings are not read.
This feels like something that needs to be addressed for performance concerns
- Suggestion.
[0002-competency-criteria-model.rst:252-268](https://github.com/openedx/openedx-core/blob/c1b178827389f64f9235d12e9761ec2e5145095b/docs/openedx_learning/decisions/0002-competency-criteria-model.rst#L252-L268): the PR adds statuses for parent hierarchy tags and parent rollup semantics but does not amend Decision 6 as requested by the ticket, leaving the architectural documentation stale.
Sounds like a good thing to address!
Raw Codex output:
Details
Review completed against ticket #643 and PR #870 at head `c1b1788` (base `a58d833`). I found one actionable issue.-
Bug.
[rollup.py:41-55](https://github.com/openedx/openedx-core/blob/c1b178827389f64f9235d12e9761ec2e5145095b/src/openedx_learning/applets/cbe/rollup.py#L41-L55),[rollup.py:84-102](https://github.com/openedx/openedx-core/blob/c1b178827389f64f9235d12e9761ec2e5145095b/src/openedx_learning/applets/cbe/rollup.py#L84-L102): the implementation ignores each child’sorderingand always reads/materializes every sibling status. This does not implement the ADR/ticket’s required ordered short-circuit evaluation. For large groups, an already-determinedAND/ORresult still performs a full sibling scan. Preserve ordering and short-circuit status reads, with a regression test proving later siblings are not read. -
Suggestion.
[0002-competency-criteria-model.rst:252-268](https://github.com/openedx/openedx-core/blob/c1b178827389f64f9235d12e9761ec2e5145095b/docs/openedx_learning/decisions/0002-competency-criteria-model.rst#L252-L268): the PR adds statuses for parent hierarchy tags and parent rollup semantics but does not amend Decision 6 as requested by the ticket, leaving the architectural documentation stale.
Verification:
- 81 targeted tests passed.
pycodestyle,pydocstyle,pylint, and targetedmypypassed.- No migrations were added.
- The PR includes AI attribution and manual testing steps.
c1b1788 to
6752464
Compare
8-10. Tests fixed. As for the bug and the suggestion. I ran into two issues with ordering. First, only |
tbain
left a comment
There was a problem hiding this comment.
Approved, thank you for addressing all those comments.
For the Codex bug it doesn't sound like it's feasible, I don't know if we need to update the ticket to amend the tickets requirements? But it sounds like you and Mary overrode it for good reasons.
6752464 to
446228f
Compare
There was a problem hiding this comment.
Thank you for your patience. Two things I'd like settled before merging, and some smaller requests inline:
- Archived groups and criteria.
record_graded_object_statuses()skips archived criteria, but this walk counts an archived criterion or an archived child group as a child with no status, so once #674/#675 start archiving things, anANDgroup with an archived child can never reachDemonstratedfor anyone who didn't attempt it first (inline onrollup.py, with the probe). - The ADR 0002 amendment the ticket asked for (and that @tbain's first review flagged) isn't in the PR. Decision 6's update flow still stops at the root group, says nothing about parent competencies or about status rows for tags that own no criteria tree, and both Decision 2 and Decision 6 still promise ordered, short-circuited evaluation, which this PR doesn't do (rightly, I think; inline on
combine_children()). Please amend it in this PR, so that the next person doesn't implementorderingbecause the ADR told them to.
I'd also like the unearnable-sub-competency warning out of the per-learner path (inline on the log.warning). I know that one is in the ticket's acceptance criteria, so if you folks feel strongly that it belongs here I won't hold the merge on it, but please read the reasoning first.
The rest of the inline comments are smaller requests and one question.
Thank you.
Claude drafted the first pass of this review and ran the CBE suite on 446228f for me (413 tests, all passing on SQLite), along with the archived-row probe quoted inline and a query count for a single rollup through three group levels and a six-level taxonomy (79 queries on the first pass, 61 when run again with nothing left to write).
|
|
||
| def _child_criteria(group_ids: set[int]) -> list[tuple[int, int]]: | ||
| """Return (criterion id, group id) for every criterion directly under ``group_ids``.""" | ||
| return list(CompetencyCriterion.objects.filter(group_id__in=group_ids).values_list("pk", "group_id")) |
There was a problem hiding this comment.
Should this and _child_groups() above (and the entry query in roll_up_competency_statuses()) filter archived=False? record_graded_object_statuses() skips archived criteria, so an archived criterion never gets a new leaf row, but this walk still counts it as a child with no status. Claude ran this on the branch with the user/tag/default_rule_profile fixtures and test_rollup_api.py's helpers:
root = CompetencyCriteriaGroup.objects.create(tag=tag)
leaf = CompetencyCriteriaGroup.objects.create(tag=tag, parent=root, logic_operator=LogicOperator.AND)
live = _make_criterion(leaf, ASSIGNMENT_1, default_rule_profile)
old = _make_criterion(leaf, ASSIGNMENT_2, default_rule_profile)
old.archived = True
old.save()
with transaction.atomic():
record_graded_object_statuses(user_id=user.id, scores=[
GradedObjectScore(object_id=ASSIGNMENT_1, fraction=Decimal("1")),
GradedObjectScore(object_id=ASSIGNMENT_2, fraction=Decimal("1")),
]) # returns 1: the archived criterion is skipped
roll_up_competency_statuses(user_id=user.id, object_ids=[ASSIGNMENT_1, ASSIGNMENT_2])That leaves the leaf group, the root, and the competency at PartiallyAttempted, and the same thing happens with an archived child group under an AND root. So once #674/#675 start archiving, a requirement that was removed from a competency keeps every learner who didn't attempt it beforehand from ever reaching Demonstrated, and nothing un-blocks it later.
I think archived rows should be invisible here, the same way they are in record_graded_object_statuses(). The status rows already stored under them can stay put, since raise-only means they can't lower anything. (@mgwozdz-unicon: Does that make sense to you?)
Not an immediate action item, but I do wonder if we should be using custom managers or Proxy models to make it extremely obvious when certain bits of code are supposed to be reading archived rows vs. not.
There was a problem hiding this comment.
Yes, Anton is also working on this ticket to filter on archived=False: #815
There was a problem hiding this comment.
Yes, i add it in Unicon#3 ( need to go via unicon review first)
| for parent, child_id, lineage in candidates: | ||
| prefix = lineage.lower() | ||
| if not any(root_lineage.startswith(prefix) for root_lineage in root_lineages.get(parent.taxonomy_id, [])): | ||
| log.warning( |
There was a problem hiding this comment.
I'd like this out of the per-learner path. Whether a sub-competency has any criteria beneath it is a property of the taxonomy, not of the learner, but this check runs once per tag level on every rollup (five of the 79 queries in Claude's count were this one), and it logs the same pair again for every grade event of every learner under that parent. While a framework is only partly covered by courses, which I'd expect to be the normal state of things for a long time, that's a warning per not-yet-covered sibling per graded subsection per learner, and the support team will learn to ignore the whole message. I know the ticket asked for this, but I think it belongs in #774's recompute command (or an authoring-time check), where it runs once and someone is actually looking at the output.
There was a problem hiding this comment.
I agree, that message that nobody would read is bad practice, so if @mgwozdz-unicon approve, than we can move it to #774
There was a problem hiding this comment.
I agree with dropping this warning from the rollup.
For the record, what I had in mind when I wrote that AC was narrower than what the code does. I was envisioning a warning only when a missing criteria group actually blocks the parent in the rollup being computed: the learner has demonstrated every sub-competency that has criteria, and the parent still can't reach DEMONSTRATED because a sibling has no criteria anywhere beneath it. For the institutions I've talked with, a parent competency's sub-competencies live in one course, so once that course is built out they all have criteria and this would be a rare misconfiguration rather than a steady state. Sub-competencies split across courses could happen, but I expect it to be uncommon.
The check as written doesn't gate on that, so it fires for every uncovered sibling on every rollup, which is the noise you describe. However, I don't think this warning provides a ton of value anyways because even the narrow version reaches the wrong person: the operator reading logs wouldn't be the person we'd want to inform, it would be the course author.
So, since the AC asked for this, @alezconsultant please remove the warning and call out the removal in the PR description. I'll work with Tammie to get this AC removed and add a note on #774 that the recompute command should report sub-competencies with no criteria anywhere beneath them, as a cheap interim. Longer term this should probably become an authoring-time check on the competency management page, which is outside MVP scope.
| __all__ = ["combine_children", "combine_parent_competency_status", "roll_up_competency_statuses"] | ||
|
|
||
|
|
||
| def combine_children( |
There was a problem hiding this comment.
FWIW, I think reading each level as a set makes total sense. That being said, ADR 0002 still talks about ordered short-circuiting in Decision 2 and in the Decision 6 update flow, and so does CompetencyCriteriaGroup.ordering's help text. So when you amend the ADR, please say that evaluation is set-based and what ordering is still for, if anything (just authoring display?).
There was a problem hiding this comment.
Reading each level as a set is correct, and ordering should play no part in evaluation. Every group combines its children with a single operator, all AND or all OR, and any mixing of the two happens through nesting rather than within one group. So the order of siblings under a group cannot change the result; the tree already settled what binds to what. Short-circuiting doesn't buy anything either: the rollup reads every child's stored status for a level in bulk before it decides anything, so stopping early would only skip a few in-memory comparisons.
Some history on why the field exists, so the ADR amendment doesn't invent a purpose for it. When I first designed this, the UX design did not show the hierarchical groupings; criteria were presented as a flat expression, and in a flat boolean expression operand order looks load-bearing. Once the UI started rendering the tree, that reason went away. It was never added for display.
Today the only thing reading it is the read API, which sorts siblings by (parent_id, ordering, id) so the tree comes back in a stable order. Whether the Rule Box UI will ever let authors reorder sibling groups is an open question, and if it doesn't, the field is a relic and should possibly just be removed. For the ADR, I'd suggest: evaluation is set-based and ignores ordering; the field currently only gives the read API a deterministic sibling order; and it is a candidate for removal unless the authoring UI turns out to need author-controlled ordering. Removal would be a follow-up, not part of this PR.
| _ModelT = TypeVar("_ModelT", bound=models.Model) | ||
|
|
||
|
|
||
| def raise_status( |
There was a problem hiding this comment.
Please have _raise_leaf_status() in api.py call this (or just replace it with this), so there's consistency for all three tables. Right now it's the same get_or_create() plus conditional UPDATE with the table baked in, and the next person who fixes a bug in one of them will miss the other.
| if created: | ||
| return 1 | ||
| # A single conditional UPDATE, never read-then-write: a stale read can only compute a value that is too low. | ||
| return manager.filter(pk=row.pk, status_id__lt=status).update(status_id=status, modified=now) |
There was a problem hiding this comment.
Please log at info when this creates or raises a row (the model, the lookup, and the status it went from and to), since "why does this learner show Demonstrated?" is the support question these tables exist to answer, and right now a rollup leaves no trace beyond the modified timestamps.
Add roll_up_competency_statuses, which recomputes every learner status above the criteria attached to the given object ids: each criteria group up to the root, the competency at the root, and every parent competency up to the top of the taxonomy. It runs in autocommit, takes no lock, and every write only raises a stored status, so concurrent, repeated and out-of-order runs for the same learner converge on the right values. Related to openedx#643 Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
1682c21 to
caafb39
Compare
|
@ormsbee
Also during the rebase process #872's one-root-per-competency constraint added, so the test that built two roots on purpose is gone. |
There was a problem hiding this comment.
Thank you for the quick turnaround. Everything from my last pass is in, or in the case of archived rows, deferred to #815, which works for me. Two small requests inline, neither of which need another pass from me: please make them and squash, and I'll merge.
Thank you.
Claude assistance
Claude drafted this pass and re-ran the CBE suite on caafb39 for me (578 tests, all passing on SQLite), re-counted last round's single-rollup probe (75 queries on the first pass and 57 on a repeat, down from 79 and 61 now that the unearnable check is gone), checked that openedx-platform#39179's task tests mock `roll_up_competency_statuses()` so the new transaction guard won't trip them, and confirmed the amended ADR renders on the Read the Docs build for this PR.| 4. ``course_id``: Nullable foreign key for the course that scopes a course-scoped subtree. Only the course-level group carries it; the groups below it leave ``course_id`` null and belong to the course through that ancestor. Immutable after creation. Root groups always have ``course_id`` null. | ||
| 5. ``name``: string | ||
| 6. ``ordering``: Indicates evaluation sequence number for this criteria group. This defines deterministic evaluation order for siblings during read-time evaluation and event-driven recomputation, and enables short-circuit evaluation. | ||
| 6. ``ordering``: A sibling sort key. The read API returns a group's children ordered by ``(ordering, id)`` so a tree comes back in a stable order. Evaluation ignores it, because the result of ``AND`` or ``OR`` over a group's children cannot depend on the order they are read in. |
There was a problem hiding this comment.
Please also capture the last part of @mgwozdz-unicon's note, that the field is a removal candidate unless the authoring UI turns out to need author-controlled order, so that the next person reading this knows it isn't settled. Something like:
| 6. ``ordering``: A sibling sort key. The read API returns a group's children ordered by ``(ordering, id)`` so a tree comes back in a stable order. Evaluation ignores it, because the result of ``AND`` or ``OR`` over a group's children cannot depend on the order they are read in. | |
| 6. ``ordering``: A sibling sort key. The read API returns a group's children ordered by ``(ordering, id)`` so a tree comes back in a stable order. Evaluation ignores it, because the result of ``AND`` or ``OR`` over a group's children cannot depend on the order they are read in. Whether authors will ever need to control that order is an open question; if the authoring UI turns out not to need it, the field should be removed in a follow-up. |
| - A group can have child groups. | ||
| - ``logic_operator`` (``AND``, ``OR``, or null) defines how children are combined; null occurs only for a group with a single child, where combining logic is moot, and the application layer treats null the same as ``OR`` (the default when there's nothing to disambiguate). | ||
| - ``ordering`` defines deterministic sibling evaluation sequence during group recomputation and enables short-circuit evaluation. | ||
| - ``ordering`` gives a group's children a stable order in the read API. Evaluation combines a group's children as a set (Decision 6), so ``ordering`` plays no part in it. |
There was a problem hiding this comment.
Likewise, the CompetencyCriteriaGroup class docstring still says that evaluation relies on ordering for "deterministic, short-circuiting evaluation order". Unlike the help_text, that doesn't need a migration, so please fix it in this PR.
Summary
Adds
roll_up_competency_statuses(*, user_id, object_ids)to the CBE applet and exports it fromopenedx_learning.api. After a grade has committed, it recomputes every learner status above the criteria attached toobject_ids:StudentCompetencyStatusat the root group.The function is meant to be called from the Celery task that runs after the grade transaction commits. ADR 0002 (Decisions 2 and 6) is amended to describe this flow and the set-based evaluation below.
How it works
ANDis Demonstrated when every child is,AttemptedNotDemonstratedas soon as any child is, and otherwisePartiallyAttemptedif any child has a status.ORis Demonstrated as soon as any child is,AttemptedNotDemonstratedonly when every child is, and otherwisePartiallyAttempted. A null operator behaves asOR. A group with nothing attempted beneath it gets no row.orderingplays no part in evaluation; it stays the sort key the read API uses for a stable sibling order, and its help text is left as is until the field's future is decided.AttemptedNotDemonstratedis written asPartiallyAttempted, becauseStudentCompetencyStatusholds onlyPartiallyAttemptedandDemonstrated.Demonstratedwhen every one is,PartiallyAttemptedwhen any has a status). A sub-competency with no criteria tree never gets a row, so it holds its parent atPartiallyAttemptedunless the parent's own criteria raise it. Reporting such tags belongs to the recompute command in [BE] Recompute learner competency statuses on operator command #774.TransactionManagementErrorwhen called inside an atomic block. The platform task calls it outside any transaction. The two rollup test modules therefore run withtransaction=True, and a fixture recreates the rows that migrations 0005 and 0009 seed, since the transactional flush removes them.raise_status()instatuses.pywrites all three status tables, including the leaf write inrecord_graded_object_statuses(). It logs atinfowhen it creates or raises a row. If the parent-competency walk reaches the taxonomy depth cap with parents still pending, it logs an error with the user id and the parent ids left out.Archived groups, criteria, and tags are handled in #815, which builds on this PR.
Manual Testing
Manual testing via the Django shell in the devstack,
docker exec -it -e PYTHONPATH=/mnt/openedx-core/src tutor_dev-cms-1 ./manage.py cms shell:Related to #643
🤖 Generated with Claude Code