read plan model output frame - #910
Conversation
🦋 Changeset detectedLatest commit: 8f1130c The changes in this PR will be included in the next version bump. This PR includes changesets to release 1 package
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
|
|
Hey Devin T. Currie (@DTCurrie) — CI is green and no reviewer is assigned yet. Could you request one when you have a chance? Auto-comment from overwatch. Will not re-nudge for 7 days. |
4be009f to
c7d2298
Compare
f27ba67 to
de62590
Compare
|
Claude (@claude) review |
|
Claude finished Devin T. Currie (@DTCurrie)'s task in 4m 45s —— View job Review
CorrectnessThe three-branch ladder in Branch 1 — envelope const declared = (entry.frame as Record<string, unknown>).primary_output_frame
if (typeof declared === 'string' && declared !== '') return declaredReads from the model envelope (the sibling of Branch 2 — const frames = model?.output_frames
const configured = Array.isArray(frames) ? (frames[0] as unknown) : undefined
if (typeof configured === 'string' && configured !== '') return configuredThe Branch 3 — The claimed-set approach is correct. The Caller truthiness check const endEffectorId = modelOutputFrame(entry, model)
if (endEffectorId) {
TestsStrong coverage across all branches and guards:
One nit (see inline): the SummaryThe fix is correct and the reasoning is airtight — |
| * last joint, so listing `gripper_mount` first makes that fallback match what each branch asserts. | ||
| */ |
There was a problem hiding this comment.
The "so" clause reads as if listing gripper_mount first is what was done, but it's actually what was avoided. The sentence is logically inverted — consider rephrasing:
| * last joint, so listing `gripper_mount` first makes that fallback match what each branch asserts. | |
| */ | |
| * `extra_link` first on purpose: the childMap fallback returns the first child of the last joint, | |
| * so if `gripper_mount` were first, a deleted branch would silently resolve to it and mask the failure. |
de62590 to
aca5ceb
Compare
aca5ceb to
8f1130c
Compare
Fixes which frame a plan model hands its children to, so a camera or gripper mounted on an arm attaches to the model's real output frame rather than to whichever link the model happened to declare last. Base of the stack, so it sits on
main.Stack
$lib/motion(make motion utils reusable #917)interpolateTrajectory, budgeting preview frames per joint unit (budget frame movement between waypoints #925)planandexecutedo-commands (add do command wiring for planning and execution #927)MoveFrameplugin (Motion plan preview #908)Frontend
modelOutputFrame(new) resolves a model's terminal frame through three branches:primary_output_frameon the model envelope, then the model config'soutput_frames[0], thensoleLeafOf.soleLeafOf(new) computes the model's one childless frame over links and joints, returningundefinedwhen there is more than one. Alinksorjointsthat is not an array is treated as empty rather than spread, so a malformed capture degrades to whatever the other list resolves instead of throwing a bareTypeErrorthat would take the whole plan render down.buildFrameContextsnow callsmodelOutputFrame. It previously readmodel?.primary_output_frameand fell back tolinks.at(-1)?.id.Why?
Why did the old read never find anything?
RDK serializes
primary_output_frameon theSimpleModelenvelope, as a sibling ofmodel.ModelConfigJSONhas no such field; its analog isoutput_frames. Somodel?.primary_output_framewas alwaysundefinedand every model fell through to the array-position fallback. All four captured plans agree on the placement: across their 29 model frames it is on the envelope 29 times and insidemodelzero.Why is the last declared link not good enough?
UnmarshalModelXMLcollects links into amap[string]*LinkConfigand then ranges that map into a slice. Go randomizes map iteration per range statement, so two parses in one process can disagree, not merely two robot restarts.jointsis appended in XML document order and is unaffected, which is exactly why the link array was the wrong thing to index. The consequence is a gripper rendered hanging off a mid-arm link, with its collision volume sweeping space the plan never occupies.Why is the ladder in this order, when RDK resolves the other way round?
SimpleModel.UnmarshalJSONignores the envelope'sprimary_output_framewhenevermodelis present and recomputes from the config, so RDK's own precedence isoutput_frames[0], thenleaves[0], then the envelope. This reads the envelope first instead. The two cannot disagree on anything RDK marshalled, because the envelope is written from the value the config produced, and reading it first means trusting what RDK resolved rather than re-deriving it. Only a hand-edited dump can tell the two orders apart.Why does the last resort refuse rather than pick?
Because that is RDK's rule, and the refusal is the whole of it.
primaryOutput = leaves[0]is reachable only whenrequireSingleLeafholds, andrequireSingleLeafislen(cfg.OutputFrames) == 0.leavesis built by ranging a Go map and is therefore unordered, so index 0 means something only because the length is pinned to one. A model with several leaves is legal as long as it names its output, which is the rung above this one, so by the time the leaf rule runs, single-leaf is the only rule RDK would apply.Why does nothing render differently on the captures in this repo?
Because the old code resolved to
links.at(-1), and on all 29 captured model frames that is the same frame the declaredprimary_output_framenames. None of the four captures declaresoutput_frames, and none has a model whose sole leaf is a joint, so the second and third branches are exercised only by tests. The fix is for models this repo has no capture of: URDF-derived arms, where link order is randomized, and models whose output frame is a joint, whichlinks.at(-1)could never name. Reviewers should not expect a visual difference on any dump here.Why does
soleLeafOfread onlylinksandjoints?A
kinematic_param_type: "DH"model carries its topology indhParams, from which RDK synthesizes<id>and<id>_jnodes and computes leaves over those.soleLeafOfsees no nodes for such a model and returnsundefined, so the branches above it answer instead. No capture uses DH, so implementing that topology has no fixture to test against.What happens with a dump captured from an older RDK?
The leaf rule answers, which is the same contract that version's
sortTransformsused.primary_output_framewas added to the envelope between RDK v0.110.0 and v0.115.0, and for anything older the field is simply absent. There is no RDK or api version floor and no proto change.Why is a model with neither
modelnorinternal_fsleft unguarded?SimpleModel.UnmarshalJSON's last branch setsprimary_output_frametofs.World().Name(), so such a model serializes"world"and the first branch would take it, parenting every child to<model>:world, a frame that does not exist. All 29 captured frames resolve to a real frame, so this is latent. Guarding it means checking the resolved id againstframes, which is a change tobuildFrameContextsrather than to the ladder, and this is the base of an 18 PR stack.Testing
npx vitest runpasses 638 tests across 66 files, up 7 frommain's 631.pnpm checkreports 0 errors and 0 warnings.pnpm lint:eslintis clean.Each branch is pinned by deleting it and naming the test that catches it:
primary_output_framereads primary_output_frame off the model envelope, not out of \model``output_frames[0]falls back to the model config\s own output_frames`soleLeafOffalls back to the model\s sole childless frame rather than its last link`, plus 2 moresoleLeafOfreturnsleaves[0]declines to pick when a model has more than one childless frameArray.isArrayguard onoutput_framesignores an output_frames that is not an arrayArray.isArrayguard onlinks/jointsdoes not throw when a model\s links is not an array`The
captured planstest is an equivalence check rather than a regression gate: on all 29 captured model frames the declared value, the sole leaf and the last declared link are the same string, so it catches a ladder that stops resolving a terminal at all, not a ladder that resolves one differently.