@@ -237,7 +237,7 @@ def _index(node: dict, in_crosslaw: str | None) -> None:
237237 details = details ,
238238 )
239239 if cross_node :
240- node .children = _crosslaw_children_as_pathnodes (cross_node , regulation )
240+ node .children = _crosslaw_children_as_pathnodes (cross_node , regulation , output )
241241 root .children .append (node )
242242 else :
243243 if name not in resolve_nodes :
@@ -255,72 +255,104 @@ 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 ) -> list [PathNode ]:
259- """Return the resolved inputs of a cross_law_reference as PathNodes .
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 .
260260
261- Nested cross_law_reference children recurse so the dashboard shows
262- the full depth (wet_brp → wet_inkomstenbelasting → ...).
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.
263273 """
264- # Load the referenced law YAML once so we can classify each input.
265274 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.
277+ nested_outputs = _output_specs_for_law (regulation )
278+
266279 children : list [PathNode ] = []
267280 seen : set [str ] = set ()
268281
269- for child in cross_node . get ( "children" , []) :
270- if not isinstance (child , dict ):
271- continue
272- nt = child .get ("node_type" )
273- cname = child .get ("name" , "" )
274-
275- if nt == "resolve" :
276- if cname in seen :
277- continue
278- seen . add ( cname )
279- spec = nested_specs .get (cname , {})
280- resolve_type , details = _classify_input ( spec ) if spec else ( "NONE" , { "type" : None , "type_spec" : None })
281- details [ "path" ] = f"$ { cname } "
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 , {})
290+ details = {
291+ "type" : out_spec . get ( "type" ),
292+ "type_spec" : out_spec .get ("type_spec" ),
293+ "path" : f"$ { name } " ,
294+ }
282295 children .append (
283296 PathNode (
284297 type = "resolve" ,
285- name = cname ,
286- result = child .get ("result" ),
287- resolve_type = resolve_type ,
288- required = bool ( spec . get ( "required" , False )) if spec else False ,
298+ name = name ,
299+ result = node .get ("result" ),
300+ resolve_type = "NONE" ,
301+ required = False ,
289302 details = details ,
290303 )
291304 )
292- elif nt == "cross_law_reference" :
293- # Cross-law ref within cross-law: find the input name on the
294- # outer law whose source.regulation+output matches this key.
295- reg , _ , out = cname .partition ("#" )
296- nested_input_name = _find_input_for_crosslaw (nested_specs , reg , out ) or out
297- if nested_input_name in seen :
298- continue
299- seen .add (nested_input_name )
300- spec = nested_specs .get (nested_input_name , {})
301- resolve_type , details = _classify_input (spec ) if spec else ("SERVICE" , {"type" : None , "type_spec" : None })
302- details ["path" ] = f"${ nested_input_name } "
303- node = PathNode (
304- type = "service_evaluation" ,
305- name = nested_input_name ,
306- result = child .get ("result" ),
307- resolve_type = "SERVICE" ,
308- required = bool (spec .get ("required" , False )) if spec else False ,
309- details = details ,
310- )
311- node .children = _crosslaw_children_as_pathnodes (child , reg )
312- children .append (node )
313-
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
314324 return children
315325
316326
317- def _find_input_for_crosslaw (specs : dict [str , dict [str , Any ]], regulation : str , output : str ) -> str | None :
318- """Find the outer input whose ``source`` matches ``regulation#output``."""
319- for name , spec in specs .items ():
320- source = spec .get ("source" ) or {}
321- if source .get ("regulation" ) == regulation and source .get ("output" , name ) == output :
322- return name
323- return None
327+ def _output_specs_for_law (law_id : str ) -> dict [str , dict [str , Any ]]:
328+ """Return ``{output_name: spec}`` for a law identified by ``$id``."""
329+ if law_id in _OUTPUT_SPEC_CACHE :
330+ return _OUTPUT_SPEC_CACHE [law_id ]
331+
332+ from machine .utils import RuleResolver
333+
334+ specs : dict [str , dict [str , Any ]] = {}
335+ try :
336+ resolver = RuleResolver ()
337+ rules = [r for r in resolver .rules if r .law == law_id ]
338+ if rules :
339+ latest = max (rules , key = lambda r : r .valid_from )
340+ data = yaml .safe_load (Path (latest .path ).read_text ())
341+ if isinstance (data , dict ):
342+ for article in data .get ("articles" , []):
343+ execution = article .get ("machine_readable" , {}).get ("execution" , {})
344+ for out in execution .get ("output" , []):
345+ name = out .get ("name" )
346+ if name :
347+ specs [name ] = out
348+ except Exception as exc :
349+ logger .debug ("Could not load output specs for %s: %s" , law_id , exc )
350+
351+ _OUTPUT_SPEC_CACHE [law_id ] = specs
352+ return specs
353+
354+
355+ _OUTPUT_SPEC_CACHE : dict [str , dict [str , dict [str , Any ]]] = {}
324356
325357
326358_INPUT_SPEC_CACHE : dict [str , dict [str , dict [str , Any ]]] = {}
0 commit comments