Skip to content

Commit 29c1e4d

Browse files
committed
feat(web): decompose cross-law expansions via action $vars
The previous compact pass still surfaced every action node inside a cross_law_reference, which gave wet_inkomstenbelasting#inkomen a dozen unrelated sub-rows (totale_belastingschuld, algemene_heffings korting, partner_*, maandelijks_inkomen, ...). Match the prod dashboard: under a cross-law service the children are the direct $var references inside the action that produced the output. For wet_inkomstenbelasting#inkomen that is exactly box1_inkomen, box2_inkomen, box3_inkomen, buitenlands_inkomen. Same-law outputs recurse one level deeper (Box1 inkomen expands into loon_uit_dienst- betrekking, winst_uit_onderneming, uitkeringen, resultaat_overige_ werkzaamheden, eigen_woning); inputs render as leaves with the existing SOURCE/SERVICE/CLAIM classification. Capped at depth 3 to avoid unbounded recursion. Output specs are cached per law alongside the input-spec cache.
1 parent 5fdcf50 commit 29c1e4d

1 file changed

Lines changed: 107 additions & 51 deletions

File tree

web/engines/regelrecht_engine/engine.py

Lines changed: 107 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -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+
327383
def _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

Comments
 (0)