@@ -255,75 +255,131 @@ def _index(node: dict, in_crosslaw: str | None) -> None:
255255 return root
256256
257257
258- def _crosslaw_children_as_pathnodes (cross_node : dict , regulation : str , output : str ) -> list [PathNode ]:
259- """Return the "break-down" children for a cross-law service.
260-
261- The prod dashboard shows, under a cross-law expansion, only the
262- *component outputs* that make up the requested output — not every
263- internal input or downstream cross-law the referenced law happens
264- to consume. For ``wet_inkomstenbelasting#inkomen`` that means
265- ``box1_inkomen``, ``box2_inkomen``, ``box3_inkomen``,
266- ``buitenlands_inkomen``, etc., because these are the outputs whose
267- sum is the parent ``inkomen``.
268-
269- The Rust trace exposes these as ``action`` nodes with a distinct
270- output name. We surface the action nodes, skip the main output
271- itself (it's already the parent), and drop raw ``resolve`` /
272- nested ``cross_law_reference`` nodes.
258+ def _crosslaw_children_as_pathnodes (
259+ cross_node : dict ,
260+ regulation : str ,
261+ output : str ,
262+ depth : int = 0 ,
263+ max_depth : int = 3 ,
264+ ) -> list [PathNode ]:
265+ """Return the decomposition children for a cross-law service.
266+
267+ Matches the production dashboard: under a cross-law expansion we
268+ show the ``$var`` references that appear as direct arguments to
269+ the action that produced the requested output. Those are the
270+ "components" the value breaks down into (e.g. ``inkomen = box1 +
271+ box2 + box3 + buitenlands``). When a component is itself an output
272+ of the same law, it becomes a nested ``service_evaluation`` so the
273+ user can drill in further (e.g. box1 → loon/winst/uitkeringen).
274+ Plain input components render as leaves.
273275 """
274- nested_specs = _input_specs_for_law ( regulation )
275- # Load the referenced law's own output declarations so we can
276- # classify each action output as amount / boolean / etc.
276+ if depth >= max_depth :
277+ return []
278+
277279 nested_outputs = _output_specs_for_law (regulation )
280+ nested_inputs = _input_specs_for_law (regulation )
281+
282+ # Find the action node for this output.
283+ action = _find_action_in_trace (cross_node , output )
284+ if not action :
285+ return []
286+
287+ # Walk the action's operation tree and pick up the direct $var refs.
288+ refs : list [tuple [str , Any ]] = _collect_direct_refs (action )
289+ if not refs :
290+ return []
278291
279- children : list [PathNode ] = []
280292 seen : set [str ] = set ()
293+ children : list [PathNode ] = []
281294
282- def _walk_actions (node : dict ) -> None :
283- if not isinstance (node , dict ):
284- return
285- nt = node .get ("node_type" )
286- name = node .get ("name" , "" )
287- if nt == "action" and name and name != output and name not in seen :
288- seen .add (name )
289- out_spec = nested_outputs .get (name , {})
295+ for ref_name , ref_value in refs :
296+ if ref_name in seen :
297+ continue
298+ seen .add (ref_name )
299+
300+ if ref_name in nested_outputs :
301+ # Same-law output: render as a nested service_evaluation so
302+ # the dashboard can recurse one level further down.
303+ out_spec = nested_outputs [ref_name ]
290304 details = {
291305 "type" : out_spec .get ("type" ),
292306 "type_spec" : out_spec .get ("type_spec" ),
293- "path" : f"${ name } " ,
307+ "path" : f"${ ref_name } " ,
294308 }
309+ node = PathNode (
310+ type = "service_evaluation" ,
311+ name = ref_name ,
312+ result = ref_value ,
313+ resolve_type = "SERVICE" ,
314+ required = False ,
315+ details = details ,
316+ )
317+ node .children = _crosslaw_children_as_pathnodes (cross_node , regulation , ref_name , depth + 1 , max_depth )
318+ children .append (node )
319+ else :
320+ # Treat as input / data-source leaf.
321+ spec = nested_inputs .get (ref_name , {})
322+ resolve_type , details = _classify_input (spec ) if spec else ("NONE" , {"type" : None , "type_spec" : None })
323+ details ["path" ] = f"${ ref_name } "
295324 children .append (
296325 PathNode (
297326 type = "resolve" ,
298- name = name ,
299- result = node . get ( "result" ) ,
300- resolve_type = "NONE" ,
301- required = False ,
327+ name = ref_name ,
328+ result = ref_value ,
329+ resolve_type = resolve_type ,
330+ required = bool ( spec . get ( "required" , False )) if spec else False ,
302331 details = details ,
303332 )
304333 )
305- return # don't descend into the action's own operation tree
306- # Skip nested cross-law bodies so we stay at a single level of
307- # breakdown; the outermost cross_node is still walked.
308- if nt == "cross_law_reference" and node is not cross_node :
309- return
310- for child in node .get ("children" , []):
311- _walk_actions (child )
312-
313- # Also fall back to nested input name matching for cases where the
314- # referenced law doesn't compute its result through discrete
315- # actions (e.g. single-expression laws).
316- _walk_actions (cross_node )
317- # Preserve YAML declaration order when available.
318- if nested_outputs :
319- order = {name : i for i , name in enumerate (nested_outputs )}
320- children .sort (key = lambda n : order .get (n .name , len (order )))
321- # Consume nested_specs reference to keep the helper's contract
322- # stable — future callers may want to classify extra nodes.
323- _ = nested_specs
334+
324335 return children
325336
326337
338+ def _find_action_in_trace (cross_node : dict , output : str ) -> dict | None :
339+ """Locate the ``action`` trace node that computes ``output``."""
340+ for child in cross_node .get ("children" , []):
341+ if not isinstance (child , dict ):
342+ continue
343+ if child .get ("node_type" ) == "action" and child .get ("name" ) == output :
344+ return child
345+ # Fall back to depth search in case the action is wrapped.
346+ for child in cross_node .get ("children" , []):
347+ if not isinstance (child , dict ):
348+ continue
349+ if child .get ("node_type" ) == "article" :
350+ for grand in child .get ("children" , []):
351+ if isinstance (grand , dict ) and grand .get ("node_type" ) == "action" and grand .get ("name" ) == output :
352+ return grand
353+ return None
354+
355+
356+ def _collect_direct_refs (action : dict ) -> list [tuple [str , Any ]]:
357+ """Collect ``(name, result)`` for $var refs inside an action value.
358+
359+ Walks the action's operation subtree and returns the first
360+ ``resolve`` node encountered per name — these are the direct
361+ components of the action's computation.
362+ """
363+ out : list [tuple [str , Any ]] = []
364+ seen : set [str ] = set ()
365+
366+ def _walk (n : dict ) -> None :
367+ if not isinstance (n , dict ):
368+ return
369+ if n .get ("node_type" ) == "resolve" :
370+ name = n .get ("name" , "" )
371+ if name and name not in seen :
372+ seen .add (name )
373+ out .append ((name , n .get ("result" )))
374+ return
375+ for c in n .get ("children" , []):
376+ _walk (c )
377+
378+ for child in action .get ("children" , []):
379+ _walk (child )
380+ return out
381+
382+
327383def _output_specs_for_law (law_id : str ) -> dict [str , dict [str , Any ]]:
328384 """Return ``{output_name: spec}`` for a law identified by ``$id``."""
329385 if law_id in _OUTPUT_SPEC_CACHE :
0 commit comments