CODAP-1485: support incremental case append for hierarchical collections - #2687
Conversation
Collection.completeCaseGroups gated its append fast path on `!parentCases`, which is never true for a collection that has a parent. Every child collection therefore re-sorted its entire case list and rebuilt its caches on every append, making streamed item creation O(N^2) for hierarchical datasets -- the shape plugins such as the Sampler use. A child collection can complete additively whenever the new cases sort to the end of the grouped order: their parents must be non-decreasing and at or after the parent of the case currently sitting last. New cases are already pushed onto the end of their parent's childCaseIds, so in that situation the existing order still holds and only the new cases need a within-parent index assigned. Everything else -- inserts, additions under an older parent, regrouping -- still rebuilds. Appending 400 samples of 10 items to a three-collection dataset: per-append cost in the final quartile drops from 6.0ms to 1.7ms, matching the flat single-collection equivalent. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Setting an item aside and then removing it leaves its id in setAsideItemIds, so an item later re-added under that id is already hidden by the time its case group is created. A child collection's rebuild repopulates caseIdToIndexMap from caseIds and so drops the placeholder index such a case is given, while appending has no equivalent step -- so the two paths disagreed about whether the hidden case was present. Fall back to rebuilding when any appended case is hidden; that costs nothing in practice, since hidden appends don't arise in the streaming scenario the append path is for. Also covers two behaviors the earlier tests left unconstrained: an appended case with no values must stay out of nonEmptyCases, and an appended hidden case must stay out of the case list entirely. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
DataSet.validateCasesForNewItems re-registered every case in caseInfoMap and cleared and rebuilt itemIdChildCaseMap outright, so both maps cost O(total cases) on every append no matter how few items arrived. Profiling the append path put them at 15% and 22% of the remaining per-append cost. Neither needs the full walk. A CaseInfo is mutated in place rather than replaced, so cases the append didn't create keep valid caseInfoMap entries and only the new ones need registering. And the childmost collection groups by item id, so every appended item forms a case of its own with no existing itemIdChildCaseMap entry to invalidate. Appending 400 samples of 10 items, per-append cost in the final quartile: 1.5ms to 0.9ms flat, 1.7ms to 1.0ms for the three-collection hierarchy -- 6.1x faster than before this branch. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
createItemsInSegments snapshotted every collection's case ids before adding and re-walked them all afterwards to work out which cases were new, costing O(total cases x collections) per request regardless of how many items the request carried. Only a case containing one of the new items can itself be new, and such a case is new precisely when the first item in it is one of them -- a case that already existed keeps whichever item it already held at the front. That makes the derivation proportional to the items added. The results are sorted back into case order so the reported ids stay in the order a scan produced. Adds coverage for two properties the previous tests left implicit: reported ids follow collection order rather than arrival order, and a new case that is hidden isn't reported at all. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
setLength built a replacement array via concat on every call, and appending items calls it once per attribute, so each append re-allocated every attribute's full value array. Growing in place also keeps strValues and values the same array in production, where they're shared, so the explicit reassignment that concat made necessary can go. Worth about 2.5% of per-append cost -- modest next to the other changes on this branch, but it removes the last per-append allocation proportional to dataset size. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
No caller ever passed it, in production or in tests, so isAppendingItems was permanently false: clearPrevCases() always ran and the flag echoed back in the return value was never read. Removing it makes that an enforced invariant rather than an accidental one -- the incremental path relies on prevCaseIds being empty, which is what stops case ids being remapped out from under the ids validateCasesForNewItems registers. The information it was meant to carry is either already present or wasn't enough. Whether the call is incremental is already told by itemIds. What the flag uniquely asserted -- that items went on the end rather than being inserted -- can't be acted on, because appending items doesn't imply appending cases: items added at the end of the item list still belong in the middle of a child collection's case order when they join an older parent. The comment now records both that trap and the shape an eventual incremental-insert path would want, which is an insert position rather than a boolean. Note DataSet.isAppendingItems is a separate, live flag that shares the name; it gates full invalidation in an onPatch handler and is untouched here. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## main #2687 +/- ##
===========================================
+ Coverage 73.02% 87.86% +14.83%
===========================================
Files 818 818
Lines 47121 47223 +102
Branches 12034 12069 +35
===========================================
+ Hits 34412 41493 +7081
+ Misses 12694 5714 -6980
- Partials 15 16 +1
Flags with carried forward coverage won't be shown. Click here to find out more. ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
codap-v3
|
||||||||||||||||||||||||||||
| Project |
codap-v3
|
| Branch Review |
main
|
| Run status |
|
| Run duration | 08m 16s |
| Commit |
|
| Committer | null |
| View all properties for this run ↗︎ | |
| Test results | |
|---|---|
|
|
0
|
|
|
0
|
|
|
82
|
|
|
0
|
|
|
384
|
| View all changes introduced in this branch ↗︎ | |
There was a problem hiding this comment.
Pull request overview
Improves DataSet/Collection incremental append performance for hierarchical collections (e.g., Sampler-style experiment → sample → item), avoiding per-append O(total cases) rebuild work and bringing hierarchical streaming performance close to flat-dataset append costs.
Changes:
- Extend
Collection.completeCaseGroups()’s incremental append fast path to child collections when appended cases provably sort to the end; fall back to rebuild when ordering/hidden-case conditions require it. - Make
DataSet.validateCasesForNewItems()updatecaseInfoMapanditemIdChildCaseMapincrementally rather than clearing/rebuilding them each append. - Reduce append overhead in related paths (
createItemsInSegments()case discovery andAttribute.setLength()growth strategy) and add focused regression tests.
Reviewed changes
Copilot reviewed 6 out of 6 changed files in this pull request and generated no comments.
Show a summary per file
| File | Description |
|---|---|
| v3/src/models/data/data-set.ts | Incrementally registers only newly created cases and maps only newly appended items to child cases during append validation. |
| v3/src/models/data/collection.ts | Enables additive completion for hierarchical appends when safe (ordering + non-hidden), with rebuild fallback for hidden/ordering-unsafe appends; simplifies updateCaseGroups() signature. |
| v3/src/models/data/attribute.ts | Changes setLength() to grow value arrays in place to avoid per-append reallocation. |
| v3/src/data-interactive/handlers/item-handler.ts | Derives newly created case IDs from added items rather than scanning all case IDs, keeping work proportional to appended item count. |
| v3/src/models/data/data-set-hierarchical-append.test.ts | Adds hierarchical append tests covering non-regroup reads, incremental map updates, indexing correctness, hidden/empty-case behavior, and ordering across parents. |
| v3/src/data-interactive/handlers/item-handler.test.ts | Adds tests for hidden-case exclusion and reporting new case IDs in collection order. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Growing strValues/numValues in place removed the notification the previous `self.strValues = ...concat(...)` produced: the arrays are volatile, so reassigning them was observable but mutating their contents is not. An append that supplied no value for a given attribute therefore left every observer of that attribute's length stuck on the pre-append value, since addCases only incChangeCounts the attributes it wrote to. get length() already reads changeCount, so reporting the growth explicitly restores the notification while keeping the in-place growth. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Appending an item to a case whose items were all hidden makes that case visible again. updateCaseGroups pushes it back onto caseIds, but it is not a new case, so it was reported to nobody: its parent was never told about it (it was hidden when the parent last collected its children), and additive completion left it out of the cached case arrays while it sat in caseIds. Register such a case with its parent, and report it so the collection rebuilds rather than completing additively. Also make appendExtendsOrder fail closed when the last completed case's parent can't be located -- treating "can't tell" as "append at the end" let arbitrary appends past the guard. This corrects behavior that was already wrong before this branch: main dropped the un-hidden case from both the middle and childmost collections (2 cases where a regroup produces 3). Both paths now match a full regroup. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Deriving the new case ids from the added items had two defects around plugin-supplied item ids, both absent from the caseIds scan it replaced. An id the data context already holds -- which the Collaborative plugin can re-send on a replayed sync message -- made every case around that item look new, because the item it was tested against is the case's first item. Such ids are now identified before the add and excluded, so a re-sent id reports no cases and fires no createCases notification. Indexing an item's case ids by collection position assumed one entry per collection, but they are appended as the item is grouped, so an id repeated within one request yielded two entries per collection and the lookup returned a parent case for a child collection -- dropping the real new case from the response. Each case now reports the collection it belongs to, which also drops the positional coupling and reads each item's case ids once instead of once per collection. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two behaviors the existing tests left unconstrained, both found by mutating the code and watching nothing fail. Every batch in this file fixed the sample as well as the experiment, so no test ever gave the childmost collection new cases under more than one parent in a single pass -- the case the per-parent index arithmetic exists for, and the shape a coalesced Sampler request actually produces. Computing the first index from the batch total rather than per parent passed the whole suite. The two lookup-map tests asserted how many entries were written, never which, so registering a case under the wrong key or mapping an item to its parent's case rather than its own also passed. The maps are now compared against a full regroup by content. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The child case map was rebuilt by walking the appended items and resolving each one's child case again -- an itemInfoMap lookup plus two more map lookups per item -- when the loop above had just resolved those same case groups to register them in caseInfoMap. Key the map from those groups instead, which also removes a second, independent way of answering which child case holds a given item. Adds coverage for a case whose only item is hidden, where the map has to key off the hidden item because there is no visible one. Nothing constrained that fallback before. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 7 out of 7 changed files in this pull request and generated no new comments.
Suppressed comments (1)
v3/src/models/data/collection.ts:595
completeCaseGroups()treats an additive invalidation withnewCaseIds=[]the same as “no append info” and falls through to the REBUILD path. That means parent collections (whereself.childis set) will do an O(total cases) rebuild even when appending items only extended existing cases (i.e. no new cases were created), which undercuts the incremental-append optimization.
Since parent collections’ cached arrays (_caseGroups/_cases/_nonEmptyCases) are still valid when no new cases were added, this branch can skip the rebuild and just bump _groupingChangeVersion so observers notice any in-place mutations (e.g. updated childItemIds).
completeCaseGroups(parentCases: Maybe<CaseInfo[]>) {
const newCaseIds = _pendingNewCaseIds
_pendingNewCaseIds = undefined
const newGroups = !_needsFullRebuild && newCaseIds?.length
? newCaseIds
.map(caseId => self.getCaseGroup(caseId))
.filter((group): group is CaseInfo => !!group)
: undefined
Streaming an item into an existing sample of an existing experiment adds no case to either parent collection, but both still re-sorted their case ids and re-derived their cached arrays -- work proportional to how many cases they already held, on every item. Measured on a three-collection dataset with items joining the newest parent: 0.52 ms/append at 100 parent cases, 0.98 at 400, 3.20 at 1600. It is the dominant cost once a session has accumulated experiments. Nothing about such a collection changed: its cases, their order and their indices are all as they were, and only the contents of existing case groups were mutated in place, which the version bump at the end already reports. So the work can be skipped outright, giving 1.96 ms/append at 1600 parent cases. Only for parent collections. In the childmost collection emptiness is derived from item values, and re-sending an item id writes new values into a case that already exists -- adding nothing while still changing which cases are empty. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Copilot's latest pass reported no new comments but included one suppressed comment: That was worth acting on — an independent multi-agent review flagged the same spot — and it's now fixed in
The suggestion's scoping to parent collections ( The residual growth is case-id rehashing, which happens on both branches and is tracked separately as CODAP-1487. |
dougmartin
left a comment
There was a problem hiding this comment.
Nice work on the performance win, and the mutation-verified test suite is a real strength. Two correctness regressions need fixing before merge: a genuinely-created case can go unreported to plugins, and setLength growing in place silently stops isNumeric from ever invalidating.
Changes requested
-
v3/src/data-interactive/handlers/item-handler.ts(lines 51-55, 70, 79): re-sending an item id the data context already holds suppresses reporting of cases the request genuinely created.resentItemIdsstrips every already-held id fromcreatingItemIds, so a case whose first item is a re-sent id is treated as pre-existing. But re-sending an id with new grouping values moves that item to a new group and forms brand-new parent cases. Reproduced againstsetupTestDataset():createItemsInSegments(dataset, [[{ id: toV2Id(existingItemId), values: { a1: "brandNewA1", a2: "brandNewA2", a3: 42 } }]])creates one new case incollections[0]and one incollections[1], yetresults[0].caseIDscomes back[]. Onmainthe same call reports both. The plugin gets nocaseIDsand nocreateCasesnotification for cases that really exist, so plugin-side state silently diverges from CODAP, and that is precisely the Collaborative-plugin scenarioresentItemIdswas added to serve. Fix: decide newness from whether the case id existed before the add rather than from the item. Capture the known case ids beforeaddCases(or better, havevalidateCasesForNewItemsexpose the case ids it registered, sincedata-set.ts:807already iterates exactly that set) and replace thecreatingItemIds.has(firstItemId)test with!knownCaseIds.has(caseId); that also makesresentItemIdsunnecessary. Note the new test "does not report pre-existing cases when an item id is re-sent" re-sends values that form no new group at any level, so it passes onmaintoo and does not constrain the new code, which is why this slipped through; please add a case that re-sends an existing id with grouping values that are new at a parent collection. -
v3/src/models/data/attribute.ts(lines 514-523, with the stale view at lines 151-154): growing the volatile arrays in place removes the only thing that invalidatedisNumeric.get isNumericreadsself.numValuescontents and never readsself.changeCount, and volatile array contents are not observable (the file documents this itself at lines 298-300), so the oldself.numValues = self.numValues.concat(...)reassignment was what invalidated the computed, with MobX deferring the reaction to the end of the enclosing action, afteraddCaseshad written values.incChangeCount()does not help a view that never readschangeCount. Reproduced on a fresh dataset with areaction(() => attr.isNumeric, ...), thendata.addCases([{ __id__: "i1", nId: 5 }]); data.validateCases(): the PR branch observes[false]andattr.isNumericstaysfalsepermanently; temporarily restoring main'ssetLengthin the same tree observes[false, true]. So an attribute that becomes numeric through streamed or appended items keeps reportingisNumeric === falseto every active observer:case-card-model.ts:92branches on it for min/max vs. categorical summaries, andcell-text-editor.tsx:38uses it to pick editor behavior. Fix: makeisNumericfollow the same convention asget lengthandget typeand readself.changeCountbefore touchingnumValues. Worth a reaction-based test too; the newsetLengthtest only coversa.length, which already readchangeCountand so could not have caught this.
Non-blocking
-
v3/src/models/data/collection.ts(lines 597-603 and 669-705), with the test asserting the new behavior atv3/src/models/data/data-set-hierarchical-append.test.ts:78: whenmustRecomputeis false,_caseGroups/_cases/_nonEmptyCaseskeep their previous array identities while theCaseInfoobjects inside them are mutated (addChildCasegrowschildCaseIds/childItemIds)._groupingChangeVersionstill bumps soget caseGroupsre-evaluates, but MobX propagates nothing downstream because the value is===its previous one. That contradicts the REBUILD path, which deliberately documents identity change as the signal at lines 694-697 ("A shallow copy preserves reference-identity semantics... so identity-comparing reactions on nonEmptyCases still fire"). Observers deriving per-parent child counts or child lists fromcollection.caseGroups(attribute-formula-adapter.getCaseChildrenCountMap,data-set-metadata.isFirstCaseOfAncestor, the case-table spacer geometry) won't be re-notified for a parent collection that gained no case of its own, andvalidateCasesForNewItemsnever bumps_caseValidationVersion, so the version counters are the only append signal. Either refresh the array identities when a descendant gained cases (_caseGroups = [..._caseGroups]and likewise, still far cheaper than the re-sort it replaces), or state explicitly in the comment at lines 597-602 that consumers must depend on_groupingChangeVersionand drop the identity-preserving copy at line 699, since the two paths currently contradict each other. -
v3/src/models/data/collection.ts(lines 385-394) andv3/src/models/data/data-set.ts(lines 785-791): an un-hidden case in a middle collection still ends up ordered differently from a full regroup. The new else-branch registers it viaaddChildCase, which appends tochildCaseIds, whereas a full regroup walksparentChildIdMapin item order and lands it in its item-order position among its siblings. Two collections (gthenm), itemsi1{g:1,m:1}andi2{g:1,m:2}, hidei1, appendi3{g:1,m:1}: incremental gives the middle collectioncaseIds = [m2case, m1case], a full regroup gives[m1case, m2case], withsymParent/caseIdToIndexMapdiffering correspondingly; the PR's owngroupingOf(data) === regroupFromScratch(data)assertion fails for this shape. Not a regression (main drops the un-hidden case entirely), but the commit message claims both paths now match a full regroup, and the new tests at lines 270-325 only cover shapes where the two orders coincide. The visible symptom is case-table rows re-ordering spontaneously on a later reload, hierarchy change, or undo. Given how rare the shape is (set-aside cases plus streamed append), the simplest fix is to prefer correctness over speed: when any collection reportsunhiddenCaseIds, fall back to a whole-datasetself.invalidateCases()rather than the per-collectioncollection.invalidateCaseGroups(). -
v3/src/models/data/collection.ts(lines 670-682, 707-708): appends are still O(N) each, so streamed creation remains O(N²) asymptotically._caseGroups,_casesand_nonEmptyCasesare each rebuilt by spreading the full existing array, andhashStringSet/hashOrderedStringSetrun unconditionally over every case id, five O(N) passes per append in the childmost collection. Measured on the three-collection Sampler shape on this branch: 0.82 ms/append at 1k items, 1.41 at 2k, 3.18 at 5k, 6.43 at 8k, still linear in N per append (main couldn't finish 8k within a 2-minute budget, so this is a large constant-factor win). No change needed here if CODAP-1487 covers it, but the PR description frames the O(N²) streamed-creation cost as fixed when it's reduced rather than removed, and a long Sampler session can accumulate well past 8k items, so it's worth adjusting the wording so it isn't lost. -
v3/src/models/data/collection.ts(lines 307-309): "Insertions currently take the rebuild path. Handling them incrementally is the opportunity still open here, and it would want the insert position rather than a flag..." describes work that doesn't exist rather than the code that does, so it will go stale independently of the code and duplicates what the tracking ticket should hold. Suggest deleting those three sentences and moving the insert-position note to CODAP-1487; the first sentence aboutitemIdssemantics and the "appending items does not imply appending cases" paragraph document real invariants and are worth keeping. -
v3/src/models/data/collection.ts(line 394) andv3/src/models/data/data-set.ts(lines 785-791): a case hidden and then un-hidden within one append pass forces an unnecessary full rebuild.unhiddenCaseIds.push(caseId)runs in both arms of theif (parentChildInfo)branch, including when the case was created earlier in the same pass and so is already innewCaseIdswith its parent already informed. A coalesced batch containing a hidden item followed by a visible item with the same group key therefore drops back to O(cases) for that collection. Moving the push into theelsearm (the branch that mints theparentChildIdMapentry) confines it to the case it's meant for. Moot if the whole-dataset-regroup fix above is taken instead. -
v3/src/models/data/collection.ts(lines 389-392): the un-hide branch can mint a phantom parent case id.self.parent?.groupKeyCaseId(self.parentGroupKey(itemId))creates and stores a new case id when the group key isn't already mapped (seegroupKeyCaseId, lines 214-222), so if the parent group key is ever unknown here the code registers aparentCaseIdwith no case group;addChildCasethen logs "missing parent case" and silently drops the child, while the minted id pollutesgroupKeyCaseIds, which is serialized. A non-creating lookup would be safer: readself.parent?.groupKeyCaseIds.get(...)or add apeekGroupKeyCaseIdview, and skip registration when it's absent.
One note on CI: the only non-green check is cypress/flake (1 flaky test, 0 failures), which doesn't look related to this change.
Growing the volatile arrays in place removed what used to invalidate isNumeric. It reads numValues but never changeCount, so the previous concat reassignment was its only invalidation signal -- MobX deferred the reaction to the end of the enclosing action, by which point addCases had written the values. The incChangeCount() added with the in-place growth doesn't help a view that never reads changeCount, so an attribute that became numeric through appended items kept reporting isNumeric === false to every active observer. Read changeCount first, as `length` and `type` already do. The cached value scanners (isInferredNumericType and friends) were never affected: incChangeCount invalidates them explicitly. The per-index accessors don't read changeCount either, but they never did react to in-place value writes, so that predates this branch. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…cases Two review findings, both about the append path claiming something it hadn't established. Deciding which cases a create request made from the *items* it added was wrong: an id the data context already holds can be re-sent with new grouping values, which moves that item into cases that really are new -- yet excluding the id suppressed exactly those. Reported as created: 0 of 2. addCases already knows which case ids it minted, so it now records them for callers to read, which is correct and still costs nothing proportional to the dataset. The record is cleared when it can't be given -- an insert rather than an append, or grouping starting over -- and the handler claims nothing then. Registering an un-hidden case with its parent appended it to that parent's children, but a full regroup places it at its item-order position among its siblings, so the two disagreed whenever a sibling already existed: the incremental path put the reappearing case last. Regroup the whole dataset instead. That is right by construction, and it retires the registration, which called groupKeyCaseId and so could mint and store a case id for a group that has none, polluting the serialized map. An un-hidden case is now reported only when it was grouped in an earlier pass; one created and un-hidden within the same pass is already in newCaseIds with its parent informed. Also hand out fresh case arrays when a collection reuses its cached contents: a descendant may have added children to those case groups, and the rebuild path treats a new array identity as the signal that something changed. Drops the comment about incremental insertion, which described work that doesn't exist; the note now lives on CODAP-1487. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Thanks — this was a genuinely valuable review. I reproduced all four substantive claims before acting on any of them, and every one held up. Both blocking items are fixed. The new-case reporting was wrong in exactly the way you describe: I confirmed 2 cases created and 0 reported. Taking your preferred fix —
Non-blocking, also taken: The un-hide ordering was real — I reproduced On array identity: you're right that the two paths contradicted each other, and my test asserted the wrong thing. Collections now hand out fresh arrays when they reuse their contents. Cost is ~8% of that optimization's win (1.96 → 2.12 ms/append at 1600 parent cases, against 3.20 before it), which seems a fair price for not silently dropping notifications. Dropped the insert-position comment; that note is on CODAP-1487 now. And I've reworded the description so it says reduced-not-removed, with your per-N numbers. Every fix is mutation-verified — each one, reverted, fails at least one test. |
dougmartin
left a comment
There was a problem hiding this comment.
Thanks for the thorough follow-up. All eight items from the last round are addressed, and I confirmed each one: resentItemIds/creatingItemIds are gone and newness is decided by case id, isNumeric reads changeCount with a reaction-based test, the UNCHANGED branch hands out fresh array identities, the un-hide case falls back to a whole-dataset regroup, unhiddenCaseIds.push is confined to the else arm, the groupKeyCaseId un-hide path is gone entirely, the speculative insert-position comment is deleted, and the description now says reduced rather than removed.
One blocking item remains, and it is the same failure mode as last round's blocking report arriving through a different door: when the fast path cannot say what it created, the handler reports that nothing was created. One of the two triggers is introduced by the fix for the un-hide note.
Changes requested
v3/src/data-interactive/handlers/item-handler.ts(lines 57-70), with the two paths that produce it atv3/src/models/data/data-set.ts(line 789) and (lines 795-804):const created = dataContext.newCaseIdsForLastAppend ?? {}treats "the append could not say what it created" as "the append created nothing", so the request returnscaseIDs: []and emits nocreateCasesnotification while cases really were created. Neither fallback populates the field:validateCasesForNewItemsreturns before writing it, and the full rebuild insidevalidateCases()never writes it at all. Reproduced againstsetupTestDataset(), both failing on this branch and correct onmain. (a) Dataset invalid when the create request arrives:dataset.removeCases([dataset.items[0].__id__])thencreateItemsInSegments(dataset, [[{ a1: "qq", a2: "qq", a3: 300 }]])gives 3 cases created, 0 reported. This is reachable from ordinary plugin traffic becausedeleteItemanddeleteCaseByinhandler-functions.ts(lines 20-22, 33-36) anditemSearch.deleteinitem-search-handler.ts(lines 16-19) all callremoveCasesinsideapplyModelChangewith no followingvalidateCases, soisValidCasesstays false until something else validates. A plugin that deletes and then creates hits it. (b) The new un-hide fallback: hide the threea1: "b"items, thencreateItemsInSegments(dataset, [[{ a1: "b", a2: "y", a3: 100 }, { a1: "zz", a2: "zz", a3: 200 }]])gives 6 cases created, 0 reported, including the whole newa1: "zz"branch that has nothing to do with the un-hidden case. The consequence is the one from last round: plugin-side state silently diverges from CODAP because CODAP reports no creation for cases that exist, and this reaches the Collaborative and Sampler flows (set-aside items, delete-then-repopulate). Fix: make the fallback paths report as well, rather than lettingundefinedmean "nothing". For (a) nothing has been grouped yet at line 789, so the collections' currentcaseIdsare a valid baseline: snapshot them there and diff after the caller'svalidateCases(). For (b) the baseline is still recoverable even though the collections ahead of the bail were already mutated: for each collection processed so far it is currentcaseIdsminusnewCaseIdsminusunhiddenCaseIds, and for the rest it is currentcaseIds. Both are O(total cases), but only on paths that are already doing a full regroup, so the streaming fast path keeps its win. Please add a test for each reproduction; the suite has neither today, which is why this passed.
Non-blocking
-
v3/src/models/data/data-set.ts(lines 795-804): the un-hide fallback returns afterself.invalidateCases()without revalidating, soaddCasescan now hand back a dataset that is invalid and internally inconsistent. The collections processed before the bail already pushed toself.caseIdsandcaseIdToIndexMap(collection.tslines 346-348, 376-379), but no collection rancompleteCaseGroups, so the cached_caseGroups/_cases/_nonEmptyCasesstill describe the pre-append state and no version counter moved. Measured on two collections with thebbranch set aside: beforeaddCases,isValidCasestrue withcaseIds.length === 1andcases.length === 1; after,isValidCasesfalse withcaseIds.length === 2andcases.length === 1. Grouping is correct again after the nextvalidateCases(), and every in-repo caller does call it, so I am not blocking on this; the insert path already leaves the dataset invalid onmainin a comparable way. Still, an append flipping a valid dataset to invalid is new, and the mixed state is exactly what commit#606bb55b3set out to eliminate. Callingself.validateCases()right afterself.invalidateCases()in the fallback would restoreaddCases' old contract at no cost on a path that is rare by construction, and it would give the fix above a validated dataset to derive the created ids from. -
v3/src/models/data/data-set.ts(line 192, with the write at line 835 and the only reset at line 1314):newCaseIdsForLastAppendis documented as "the cases created by the most recentaddCases", but nothing except the nextaddCasesclears it, so afterremoveCases,setCaseValues, a full regroup or an undo it still advertises the previous append's ids, and those ids can name cases that no longer exist. Today's only consumer reads it in the same statement sequence and filters bygetCaseIndex(...) ?? -1, so nothing is broken now, but it is a public volatile prop whose stated contract does not hold. Either narrow it so it cannot be misused (return the record fromaddCases, or expose a consume-and-clear accessor) or clear it ininvalidateCases()andremoveCases(). If it stays as-is, the comment should say it is valid only until the next mutation. -
v3/src/models/data/attribute.ts(lines 517-526), affecting the accessors at lines 404-420: theisNumericfix covers the view that was reported, but growing the arrays in place narrows notification for the per-index accessors too (value,isValueNumeric,numValue,strValue,boolean). Your audit note is right that these never reacted to writes at existing indices, butmain'sself.strValues = self.strValues.concat(...)was a reassignment and so did notify on growth:reaction(() => a.strValue(2), ...)followed bya.setLength(3)observes nothing on this branch. I could not substantiate a broken consumer, since the dataset-level wrappers are equally non-reactive onmainand any realistic reader of a new index also readsitems/itemIds/case ids, which do change observably. Reporting the mechanism rather than a symptom: either give those five the same one-linevoid self.changeCounttreatment so the file follows one convention, or note insetLength's comment that growth is now observable only throughchangeCount. -
v3/src/models/data/collection.ts(lines 322-324, 383-390, 461):unhiddenCaseIdsis only ever tested for emptiness (data-set.tsline 795), yet it is returned as a list, which implies a completeness contract it does not have, since the caller stops at the first collection that reports one. Either return{ newCaseIds, hasUnhiddenCases }or document that the contents are informational only. -
v3/src/models/data/collection.ts(lines 634-640):const childCaseIds = self.parent?.getCaseGroup(parentCaseId)?.childCaseIds ?? []followed byfirstNewIndex = childCaseIds.length - groups.lengthwrites negativesymIndexvalues with no warning if the parent case group is missing. The codebase already treats "parent case id with no case group" as worth a warning (collection.tslines 499-502), which is the same condition that would emptychildCaseIdshere. I could not construct a reachable input, so treat this as defensive: skip the group or fall back to the rebuild branch when the parent group is undefined, rather than computing an index from a defaulted empty array. -
v3/src/models/data/collection.ts(line 599):newCaseIds?.length !== 0reads as "is non-empty" but is also carrying theundefinedcase, which is only explained by the comment above it.newCaseIds == null || newCaseIds.length > 0would put that at the expression. Related nit: the new comments atitem-handler.tslines 57-60 anddata-set.tslines 189-191 use--where the surrounding comments in both files use a dash character.
On CI: the only non-green check is still cypress/flake (1 flaky test, 0 failures), which does not look related.
The handler treated "the append couldn't say what it created" as "the append created nothing", so a create request could return no caseIDs and emit no createCases notification while cases really were created. Three paths reach it, and the un-hide fallback added earlier on this branch is one of them: - the data context is already invalid when the request arrives, which ordinary plugin traffic produces because deleteItem, deleteCaseBy and itemSearch.delete all removeCases without revalidating — a plugin that deletes and then creates reported 0 of 3 - an appended item un-hides a case, which abandons the additive pass — reported 0 of 6, including a whole new branch unrelated to the un-hidden case The handler now brings grouping up to date before adding, so the additive pass has a consistent base and reports the ids it mints directly. That replaces an attempt to reconstruct a baseline from the collections' cached case ids, which are stale or empty precisely when the dataset arrives invalid: a plugin deleting a group's items and re-creating them was told 1 case of the 3 it made, because the regenerated id was still in the stale baseline, and a create following a document snapshot was told 11 for 3. Validating belongs to the request, not to addCases: only this caller needs the guarantee, and of the ten-odd others the v2 importer, the csv importer and undo replay would have paid a full regroup they discard. addCases is unchanged, so inserting still defers, and the accessor says it cannot report rather than reporting nothing. Reads go through takeCaseIdsCreatedByLastAppend(), which answers at most once, for the addCases immediately preceding it. Reading consumes the answer and any intervening change to the cases discards it, so the ids can never name cases that have since gone — a property of the design rather than of an audit of which mutations might invalidate them. It declines to answer rather than claiming nothing was created when a value-only invalidation means the items were never grouped at all (CODAP-1486). The un-hide fallback also revalidates now, so an append no longer hands back a dataset that is both invalid and holding case ids its cached case arrays don't know about. Also guards against negative within-parent indices when a parent case group is missing, matching the warning addChildCase already emits for that condition. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
getAfterPosition tested the resolved item index for truthiness, so index 0 -- a perfectly good position -- read as "no position found" and the request fell through to appending at the end instead of inserting after the case the caller named. The two sibling checks either side of it already guard with != null and ??; this one didn't. Reachable only through the plugin API, since no in-repo caller passes `after`. Pre-existing rather than introduced here; found while reviewing this branch and fixed in passing, as it is a one-word change next to code this PR already touches. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Thanks — the blocking item was real and is fixed. Both triggers reproduced exactly as you described: 0 of 3 with an invalid data context on arrival, 0 of 6 through the un-hide fallback. Your reachability claim holds too — On your suggested fix. Your (b) is implemented as you described and holds up: that path enters with grouping valid, so subtracting what the pass pushed does reconstruct what each collection held beforehand. Your (a) I couldn't make work. It rests on the collections' current So (a) is handled by validating before the add instead. With grouping current, the additive pass has a consistent base and reports the ids it mints directly, and no baseline is involved. Where that validate lives. In Your non-blocking items. All taken except two:
Un-hidden cases count as created. A case that was set aside and is made visible again by an appended item is reported as created. That matches Also included, separately. The new tests derive ground truth independently — a case belongs to a request exactly when every item in it came from that request — rather than from the dataset's own |
dougmartin
left a comment
There was a problem hiding this comment.
Looks good 👍
All six items from the last round are addressed, and I confirmed each against the code. The blocking one is fixed at both triggers: an invalid data context on arrival is handled by validating before the add (item-handler.ts:55), and the un-hide fallback records a baseline and recovers the answer by diffing (data-set.ts:817-828, data-set.ts:889-904). I mutation-verified the baseline reconstruction: collapsing it to [...processed.caseIds] fails both new un-hide tests. newCaseIdsForLastAppend is now behind a consume-and-clear accessor and cleared in three places, so it can't hand a later reader stale ids. The two declined non-blocking items both hold up: unhiddenCaseIds' contents became load-bearing when the baseline started using them, so the completeness objection no longer applies, and leaving the five per-index accessors non-reactive is the right call now that setLength's comment records the mechanism. The getAfterPosition truthiness fix is correct and constrained by its test.
A few non-blocking notes (take or leave):
-
v3/src/models/data/data-set.ts(lines 829-831): theself.validateCases()that closes last round's invalid-and-inconsistent note isn't constrained by any test. I deleted the line and all 60 suites undersrc/models/dataandsrc/data-interactivestill passed, because every test reaching this path callsvalidateCases()itself right afterwards, andtakeCaseIdsCreatedByLastAppendre-validates too. Given the PR states every branch of the new code is mutation-verified, worth closing: in "restores a case that an appended item un-hides, in a middle collection", assertexpect(data.isValidCases).toBe(true)anddata.collections.forEach(c => expect(c.cases.length).toBe(c.caseIds.length))immediately after the un-hidingaddCasesand before the explicitvalidateCases(). Both hold on the current code. -
v3/src/data-interactive/handlers/item-handler.ts(line 55):if (!dataContext.isValidCases) dataContext.validateCases()duplicates the guardvalidateCases()already opens with (data-set.ts:737), and reads an observable getter to do it. CallingdataContext.validateCases()unconditionally is equivalent; the comment above it is the part that earns its keep. -
v3/src/models/data/data-set.ts(lines 186-196):newCaseIdsForLastAppendandappendBaselineCaseIdsare public volatile props, so the consume-and-clear contract is advisory. The whole point of the accessor is that "couldn't tell" must never be read as "created nothing", but anything can readdataSet.newCaseIdsForLastAppenddirectly, repeatedly, and findundefined, which walks straight back into the failure mode this PR has now fixed twice. A_prefix would at least signal it; moving both into the.extend(self => ...)closure alongside_isValidCases, exposed only through the settervalidateCasesForNewItemsuses and the take accessor, would make it structural. -
v3/src/models/data/data-set.ts(lines 817-822): the baseline loop identifies the bailing collection two different ways,index < newCaseIdsByCollection.lengthand then the object identity testprocessed === collection, when both are saying "index k" withk === newCaseIdsByCollection.lengthby construction. The identity comparison also assumesself.collectionsreturns the same MST node instances across two accesses, which is true but is an unnecessary dependency for what is arithmetic. Hoistingconst bailIndex = newCaseIdsByCollection.lengthand branching onindex < bailIndex/index === bailIndex/ else would drop it. The logic itself is correct: I checked k = 0, middle, and last. -
v3/src/models/data/collection.ts(lines 636-648): the guard added for last round's note isif (!childCaseIds), but the failure it prevents (a negativesymIndexfromchildCaseIds.length - groups.length) also occurs wheneverchildCaseIds.length < groups.lengthwith a non-empty array. I could not construct a reachable input, sinceaddChildCaseandgetCaseGroupresolve the parent through the same map pair, so this stays defensive-only, same standing as the original note.if (!childCaseIds || childCaseIds.length < groups.length)with the same warn-and-skip would cover it. -
v3/src/models/data/data-set.ts(line 897): theself.validateCases()inside the take accessor can't do work on any reachable path, since the fallback validates at line 831 and the only caller validates again atitem-handler.ts:62. Harmless, but it makes the accessor look callable against an invalid dataset when the design requires the opposite. Either drop it or add a line saying it's belt-and-braces. -
v3/src/data-interactive/handlers/item-handler.test.ts(line 161),v3/src/models/data/data-set.test.ts(lines 1367, 1406),v3/src/models/data/data-set-hierarchical-append.test.ts(line 63): the--comment style was fixed in the source files last round but reappears in the test comments added by the last two commits. Same one-character change as before, to match the dash character the surrounding comments use. -
v3/src/models/data/data-set.test.ts(line 1341):describe("caseIdsCreatedByLastAppend")names something that doesn't exist; the method istakeCaseIdsCreatedByLastAppend, and the take semantics are exactly what four of the six tests assert. -
v3/src/models/data/data-set.test.ts(lines 1350-1351): two consecutive blank lines aftermakeGrouped(). ESLint doesn't flag it, but nothing else in the file does it.
CI is green (27 passing), and lint is clean on all eight changed files.
Encapsulate the two fields the reporting mechanism keeps. They were public volatile props, so consume-and-clear was advisory: anything could read them directly and repeatedly, or find undefined and mistake "the append couldn't say" for "the append created nothing" -- the failure this branch has now fixed twice. They are closure variables now, reachable only through a setter pair and a take that really does consume, so the contract holds by construction rather than by convention. They also stop being observable writes that nothing observed, matching the other cache guards alongside them. The validateCases() that stops an append handing back a dataset that is both invalid and holding case ids its cached case arrays don't know about was constrained by nothing -- deleting it passed all 476 tests. The un-hide test now asserts validity and caseIds/cases agreement before validating, and fails without it. Also: drop the isValidCases check the handler did before calling validateCases, which opens with the same guard; widen the missing-parent guard to the arithmetic it protects rather than one instance of it; identify the bailing collection by index rather than by object identity; record why the take accessor validates when no reachable caller needs it to; and fix a describe naming a method that doesn't exist, plus comment-dash style in the tests. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Thanks for the approval. All nine notes are in, pushed as Two are worth flagging because they changed code rather than comments, and landed after your approval: The two fields are encapsulated now. You were right that the consume-and-clear contract was advisory while they were public props. They're closure variables alongside The The rest as you suggested: the handler calls On the take accessor I kept the call rather than dropping it. You're right nothing reachable needs it, but the failure it guards against is diffing a baseline against grouping that hasn't caught up, which is the defect this branch fixed twice — and the last thing I removed as unreachable turned out to be load-bearing as soon as the design shifted. It's commented as belt-and-braces. Full suite green locally (3757); CI is running on the push. |
Summary
DataSethas an optimized incremental-append path for new items, but it only fully applied to flat datasets. For hierarchical ones — including the Sampler plugin, which builds three collections (experiments→samples→items) — every append did an O(total cases) rebuild per child collection, making streamed item creation O(N²).The append fast path in
Collection.completeCaseGroupswas gated on!parentCases, which is never true for a collection that has a parent. The earlier stages were already additive and correct for hierarchies; only the completion stage regressed.Streamed item creation through the plugin path is 7.4× faster at the tail — 7.50 ms per request down to 1.01 ms at 4000 items.
Results
Appending 400 samples of 10 items to a three-collection dataset — ms per append, by quartile:
End-to-end through the plugin path (
createItemsInSegments), ms per request:7.4× faster at the tail. This reduces the O(N²) cost of streamed creation rather than removing it — each append is still O(N), since the cached case arrays are rebuilt by spreading and
completeCaseGroupsrehashes every case id on both branches. Making the path genuinely O(new cases) is CODAP-1487.What changed
The append path itself — a child collection completes additively when the new cases provably sort to the end of the grouped order; inserts, additions under an older parent, and regrouping still rebuild. Streaming into an existing sample of an existing experiment no longer re-sorts either parent collection, which was O(accumulated parent cases) on every item.
The lookup maps —
caseInfoMapwas re-registered wholesale anditemIdChildCaseMapcleared and rebuilt on every append; both are now incremental.What a create request reports —
createItemsInSegmentsno longer diffs every collection's case ids before and after. The add reports the case ids it minted, read through a consume-once accessor. The request brings grouping up to date first so the additive pass can run, sincedeleteItem,deleteCaseByanditemSearch.deleteallremoveCaseswithout revalidating, so delete-then-create arrives with grouping invalid.Correctness fixes found along the way — an appended item that un-hides a set-aside case is restored correctly (
maindropped it from both the middle and childmost collections);setLengthgrowing in place no longer loses the MobX notification that reassignment provided, andisNumericreadschangeCountlikelengthandtype;getAfterPositionno longer treats item index 0 as "no position".Review
Approved by @dougmartin after two rounds of requested changes, alongside three multi-agent review passes. Between them they found roughly twenty defects, all addressed. Two behaviours were kept deliberately and are called out in the thread: a set-aside case made visible again is reported as created, matching
main; and the five per-index attribute accessors stay non-reactive, since making them readchangeCountwould re-render the case table on every value write.Testing
Twenty-three tests across
data-set-hierarchical-append.test.ts,data-set.test.tsanditem-handler.test.ts. Each assertion is mutation-verified — reverting the production change it covers fails at least one test. Several exist because mutation showed an earlier version constrained nothing, and the reporting tests derive ground truth independently (a case belongs to a request exactly when every item in it came from that request) rather than from the dataset's own case ids.Follow-ups
main.completeCaseGroups, andcaseIdToIndexMapencoding a hidden case two different ways.Fixes CODAP-1485
🤖 Generated with Claude Code