diff --git a/packages/imandrax-api-models/src/imandrax_api_models/context_utils.py b/packages/imandrax-api-models/src/imandrax_api_models/context_utils.py index c8955789..1c55fe60 100644 --- a/packages/imandrax-api-models/src/imandrax_api_models/context_utils.py +++ b/packages/imandrax-api-models/src/imandrax_api_models/context_utils.py @@ -15,6 +15,7 @@ Position, VerifyRes, ) +from imandrax_api_models.region_decomp import EnrichedDecomposeRes from imandrax_api_models.yaml_utils import ImandraXAPIModelDumper @@ -317,3 +318,19 @@ def format_decomp_res(decomp_res: DecomposeRes) -> str: data = remove_art_and_task_fields(data) return yaml.dump(data, Dumper=ImandraXAPIModelDumper, width=120) + + +def format_enriched_decomp_res(decomp_res: EnrichedDecomposeRes) -> dict[str, Any]: + # TOOD: use remove_fields_rec + d: dict[str, Any] = {} + if decomp_res.regions_str is not None: + enriched_regions = decomp_res.regions() + d['descr'] = f'Decomp succeeded with {len(enriched_regions)} regions' + d['regions'] = enriched_regions + else: + d['descr'] = 'Decomp failed' + d |= remove_art_and_task_fields(decomp_res.model_dump()) + d.pop('regions_str') + d.pop('region_groups') + + return d diff --git a/packages/imandrax-api-models/src/imandrax_api_models/region_decomp/__init__.py b/packages/imandrax-api-models/src/imandrax_api_models/region_decomp/__init__.py index f25edc2c..6be0cc01 100644 --- a/packages/imandrax-api-models/src/imandrax_api_models/region_decomp/__init__.py +++ b/packages/imandrax-api-models/src/imandrax_api_models/region_decomp/__init__.py @@ -1,168 +1,112 @@ -"""Post-processing (Hierarchical groupping) for region decomposition.""" +"""Post-processing (hierarchical grouping) for region decomposition.""" from __future__ import annotations -import itertools -from collections.abc import Callable -from dataclasses import dataclass -from functools import partial, reduce -from typing import Any, Literal, NoReturn, Protocol, Self, TypedDict +from collections.abc import Callable, Mapping, Sequence +from dataclasses import asdict +from functools import reduce +from typing import NoReturn, Self, TypedDict -import yaml from devtools import pformat from imandrax_api.lib import RegionStr +from pydantic import BaseModel, Field, model_validator -from imandrax_api_models.proto_models import DecomposeRes, Error -from imandrax_api_models.yaml_utils import str_representer +from imandrax_api_models.proto_models import DecomposeRes from .icicle_widget import mk_icicle_widget_html -@dataclass -class HumDecomposeRes: - """Human readable decomp result""" +class EnrichedDecomposeRes(DecomposeRes): + """A `DecomposeRes` augmented with hierarchical region grouping.""" - _variants: ( - tuple[Literal['Success'], list[RegionGroup]] - | tuple[Literal['Fail'], list[Error]] + region_groups: list[RegionGroup] = Field( + default_factory=list, + description='Region groups grouped by constraints, containing child groups recursively. Empty when no regions are available (decomposition error).', ) - @classmethod - def mk_success(cls, pl: list[RegionGroup]) -> Self: - return cls(_variants=('Success', pl)) - - @classmethod - def mk_fail(cls, pl: list[Error]) -> Self: - return cls(_variants=('Fail', pl)) - - @classmethod - def from_decomp_res(cls, v: DecomposeRes) -> HumDecomposeRes: - return hum_of_decomp_res(v) + @model_validator(mode='after') + def populate_region_groups(self) -> Self: + if not self.region_groups and self.regions_str: + self.region_groups = group_regions(self.regions_str) + return self @classmethod - def from_regions(cls, regions: list[RegionStr]) -> HumDecomposeRes: - groups = group_regions(regions) - return HumDecomposeRes.mk_success(groups) + def from_decomp_res(cls, v: DecomposeRes) -> EnrichedDecomposeRes: + return cls.model_validate(v.model_dump()) + + def regions(self) -> JSONArray: + """Leaf region groups (concrete regions) with hierarchical grouping info.""" + leaf_groups = get_leaf_groups(self.region_groups) + ds = [] + for leaf_group in leaf_groups: + d: JSONObject = {} + assert leaf_group.region is not None, 'Leaf group must be concrete' + d['label_path'] = '.'.join(map(str, leaf_group.label_path)) + d['weight'] = leaf_group.weight + d |= asdict(leaf_group.region) + ds.append(d) + return ds def to_tree_str( self, *, depth_limit: int | None = None, - summarize: RegionGroupSummarizer | None = None, + summarize: Callable[[RegionGroup], str] | None = None, ) -> str: - match self._variants: - case ('Fail', errs): - return pformat(errs, indent=2) - case ('Success', groups): - summarize_ = summarize or default_region_group_summary - lines: list[str] = [] - for i, group in enumerate(groups): - is_last = i == len(groups) - 1 - _tree_lines( - lines, - group, - prefix='', - is_last=is_last, - depth_limit=depth_limit, - summarize=summarize_, - ) - return '\n'.join(lines) - - def summary(self) -> dict[str, str | int] | None: - match self._variants: - case ('Fail', _): - return None - case ('Success', groups): - return { - 'n_regions': _count_leaf_regions(groups), - # 'n_regions': sum(rg.n_regions() for rg in groups), - # 'n_leaf_regions': _count_leaf_regions(groups), - # 'max_depth': _max_tree_depth_of_groups(groups), - # 'max_label_depth': _max_label_depth_of_groups(groups), - # 'max_constraints_per_region': _max_constraints_per_region(groups), - } - - @staticmethod - def dumper_func(depth_limit: int | None = None) -> Callable[..., str]: - dumper_cls = mk_dumper(depth_limit=depth_limit) - return partial( - yaml.dump, - Dumper=dumper_cls, - default_flow_style=False, - sort_keys=False, + if self.errors: + return pformat(self.errors, indent=2) + return render_region_groups( + self.region_groups, depth_limit=depth_limit, tree_repr=summarize ) - def to_dict_hierarchical( - self, - depth_limit: int | None = None, - ) -> dict[str, Any]: - match self._variants: - case ('Fail', errs): - return {'errors': errs} - case ('Success', groups): - return {'summary': self.summary(), 'region_groups': groups} - - def to_dict_flat( - self, - ) -> dict[str, Any]: - match self._variants: - case ('Fail', errs): - return {'errors': errs} - case ('Success', groups): - indexed_groups = indexed_of_region_groups(groups) - return {'summary': self.summary(), 'region_groups': indexed_groups} - def _repr_html_(self) -> str: - match self._variants: - case ('Fail', errs): - return f'
{pformat(errs, indent=2)}'
- case ('Success', groups):
- return mk_icicle_widget_html(groups)
-
-
-def hum_of_decomp_res(decomp_res: DecomposeRes) -> HumDecomposeRes:
- if decomp_res.err is None:
- regions = decomp_res.regions_str
- if regions is None:
- raise ValueError(
- 'DecomposeRes has no `regions_str`; humanizing regions requires '
- 'the decomposition to be requested with `string_results=True`.'
- )
- groups = group_regions(regions)
- return HumDecomposeRes.mk_success(groups)
-
- else:
- return HumDecomposeRes.mk_fail(decomp_res.errors)
-
-
-@dataclass
-class RegionGroup:
+ if self.errors:
+ return f'{pformat(self.errors, indent=2)}'
+ return mk_icicle_widget_html(self.region_groups)
+
+
+type JSONValue = (
+ str | int | float | bool | None | Mapping[str, JSONValue] | Sequence[JSONValue]
+)
+type JSONObject = dict[str, JSONValue]
+type JSONArray = list[JSONValue]
+
+
+class RegionGroup(BaseModel):
"""
A hierarchical group of regions sharing constraints.
Attributes:
constraints:
- Full accumulated constraint path from root to this node (root-first).
- `constraints[-1]` is the constraint introduced at this node's own level.
- label_path:
- Positional index path from root to this node (root-first, 1-indexed).
- Each element is the sibling index at that depth. Displayed as e.g. `1.2.3`.
- Levels where a constraint applies to all regions are skipped, so the path
- length may be shorter than the tree depth.
- region:
- The concrete region, if this group contains exactly one.
children:
Sub-groups under this node.
weight:
- Number of regions in the `has` partition at this node's level.
"""
- constraints: list[str]
- label_path: list[int]
- region: RegionStr | None
- children: list[RegionGroup]
- weight: int
+ constraints: list[str] = Field(
+ description=(
+ 'Full accumulated constraint path from root to this node (root-first).'
+ "`constraints[-1]` is the constraint introduced at this node's own level."
+ )
+ )
+ label_path: list[int] = Field(
+ description=(
+ 'Positional index path from root to this node (root-first, 1-indexed).'
+ 'Each element is the sibling index at that depth. Displayed as e.g. `1.2.3`.'
+ 'Levels where a constraint applies to all regions are skipped, so the path'
+ 'length may be shorter than the tree depth.'
+ )
+ )
+ weight: int = Field(
+ description="Number of regions in the partition at this node's level."
+ )
+ region: RegionStr | None = Field(
+ default=None, description='The concrete region. Present iff at leaf nodes.'
+ )
+ children: list[RegionGroup] = Field(
+ default_factory=list, description='Sub-groups under this node.'
+ )
def n_regions(self) -> int:
"""Total regions in this subtree, including self."""
@@ -173,155 +117,88 @@ def n_descendant_regions(self) -> int:
return self.n_regions() - 1
def n_leaf_regions(self) -> int:
- """Total leaf regions in this subtree, including self if no children."""
+ """Total leaf regions in this subtree, counting self if no children."""
if not self.children:
return 1
return sum(c.n_leaf_regions() for c in self.children)
- def to_dict(self) -> dict[str, Any]:
- """Core fields shared by all serialization formats."""
- d: dict[str, Any] = {
- 'label_path': '.'.join(map(str, self.label_path)),
- 'constraints': self.constraints,
- 'introduced_constraint': self.constraints[-1] if self.constraints else '',
- 'weight': self.weight,
- 'n_children_regions': len(self.children),
- 'n_descendant_regions': self.n_descendant_regions(),
- 'n_leaf_regions': self.n_leaf_regions(),
- }
+ def describe(self) -> JSONObject:
+ d: JSONObject = {}
+ d['label_path'] = '.'.join(map(str, self.label_path))
+ d['constraints'] = self.constraints
+ d['introduced_constraint'] = self.constraints[-1] if self.constraints else ''
+ d['weight'] = self.weight
+ d['n_children_regions'] = len(self.children)
+ d['n_descendant_regions'] = self.n_descendant_regions()
+ d['n_leaf_regions'] = self.n_leaf_regions()
if (r := self.region) is not None:
d['invariant'] = r.invariant_str
d['example_input'] = r.model_str
d['example_output'] = r.model_eval_str
return d
- def to_json_dict(self) -> dict[str, Any]:
- """Serialize to a d3-hierarchy-compatible dict."""
- d = self.to_dict()
+ def to_json_dict(self) -> JSONObject:
+ """Serialize to a d3-hierarchy-compatible dict, recursing into children."""
+ d = self.describe()
if self.children:
d['children'] = [c.to_json_dict() for c in self.children]
return d
-
-def group_regions(regions: list[RegionStr]) -> list[RegionGroup]:
- """Group regions hierarchically based on constraints."""
- return _loop_group_regions([], [], regions)
-
-
-@dataclass
-class IndexedRegionGroup:
- """Equivalent to RegionGroup, but in flat structure."""
-
- id: str
- constraints: list[str]
- label_path: list[int]
- region: RegionStr | None
- children: list[str]
- weight: int
- depth: int # 1-indexed depth
-
-
-def indexed_of_region_groups(groups: list[RegionGroup]) -> list[IndexedRegionGroup]:
- id_generator = (str(i) for i in itertools.count())
-
- def gen_id() -> str:
- return next(id_generator)
-
- def loop(
- group_and_id_lst: list[tuple[RegionGroup, str]],
- acc: list[IndexedRegionGroup],
- depth: int,
- ) -> None:
- for rg, id in group_and_id_lst:
- c_ids = [gen_id() for _ in rg.children]
-
- irg = IndexedRegionGroup(
- id=id,
- constraints=rg.constraints,
- label_path=rg.label_path,
- region=rg.region,
- children=c_ids,
- weight=rg.weight,
- depth=depth,
- )
- acc.append(irg)
-
- children_group_and_id_lst = list(zip(rg.children, c_ids, strict=True))
- loop(children_group_and_id_lst, acc, depth + 1)
-
- initial_ids = [gen_id() for _ in groups]
- group_and_id_lst = list(zip(groups, initial_ids, strict=True))
- acc: list[IndexedRegionGroup] = []
- loop(group_and_id_lst, acc, 1)
- return acc
-
-
-def _max_label_depth_of_groups(groups: list[RegionGroup]) -> int:
- """
- Max length of any label_path in the tree (index-chain depth).
-
- Counts collapsed singleton levels since label_path is built before
- singleton promotion in `_loop_group_regions`.
- """
- init: int = 1
-
- def loop(groups: list[RegionGroup]):
- label_lengths: list[int] = [len(rg.label_path) for rg in groups]
- nonlocal init
- init = max(init, (max(label_lengths) if label_lengths else 0))
- for rg in groups:
- loop(rg.children)
-
- loop(groups)
- return init
-
-
-def _max_tree_depth_of_groups(groups: list[RegionGroup]) -> int:
- """Max nesting depth of RegionGroup nodes (matches visual tree)."""
-
- def depth(rg: RegionGroup) -> int:
- return 1 + max((depth(c) for c in rg.children), default=0)
-
- return max((depth(g) for g in groups), default=0)
-
-
-def _count_leaf_regions(groups: list[RegionGroup]) -> int:
- """Count of nodes carrying a concrete RegionStr (region is not None)."""
- total = 0
- for g in groups:
- if g.region is not None:
- total += 1
- total += _count_leaf_regions(g.children)
- return total
+ def repr_line(self) -> str:
+ """One-line representation of the region group."""
+ d = self.describe()
+ parts: list[str] = []
+ parts.append(f'[{d["label_path"]}]')
+ parts.append(f"new_constraint='{d['introduced_constraint']}'")
+ if d.get('invariant'):
+ parts.append(f"invariant='{d['invariant']}'")
+ n_leaf_regions = d['n_leaf_regions']
+ if n_leaf_regions != 1:
+ parts.append(f'n_leaf_regions={n_leaf_regions}')
+ else:
+ parts.append('is_leaf=True')
+ return ' '.join(parts)
-def _max_constraints_per_region(groups: list[RegionGroup]) -> int:
- """Longest constraints list across all nodes."""
+def get_leaf_groups(groups: list[RegionGroup]) -> list[RegionGroup]:
+ leaves = []
+ for group in groups:
+ if not group.children:
+ leaves.append(group)
+ else:
+ leaves.extend(get_leaf_groups(group.children))
+ return leaves
- def walk(rg: RegionGroup) -> int:
- return max(len(rg.constraints), *(walk(c) for c in rg.children), 0)
- return max((walk(g) for g in groups), default=0)
+def group_regions(regions: list[RegionStr]) -> list[RegionGroup]:
+ """Group regions hierarchically based on constraints."""
+ return _loop_group_regions([], [], regions)
-class RegionGroupSummarizer(Protocol):
- def __call__(self, group: RegionGroup) -> str: ...
+# Tree rendering
+# ====================
-def default_region_group_summary(group: RegionGroup) -> str:
- label = '.'.join(map(str, group.label_path))
- # rg_constraints is the full path from root; [-1] is this node's own constraint.
- constraint = group.constraints[-1] if group.constraints else '?'
- invariant: str | None = None
- if (region := group.region) is not None:
- invariant = region.invariant_str
- parts = [
- f'[{label}]',
- f'{constraint=}',
- f'{invariant=}',
- f'(w={group.weight}, n_children={len(group.children)}, n_descendants={group.n_descendant_regions()})',
- ]
- return ' '.join(parts)
+def render_region_groups(
+ groups: list[RegionGroup],
+ *,
+ depth_limit: int | None = None,
+ tree_repr: Callable[[RegionGroup], str] | None = None,
+) -> str:
+ """Render a forest of `RegionGroup`s as a tree in text."""
+ tree_repr_ = tree_repr or RegionGroup.repr_line
+ lines: list[str] = []
+ for i, group in enumerate(groups):
+ is_last = i == len(groups) - 1
+ _tree_lines(
+ lines,
+ group,
+ prefix='',
+ is_last=is_last,
+ depth_limit=depth_limit,
+ summarize=tree_repr_,
+ )
+ return '\n'.join(lines)
def _tree_lines(
@@ -331,7 +208,7 @@ def _tree_lines(
prefix: str,
is_last: bool,
depth_limit: int | None,
- summarize: RegionGroupSummarizer,
+ summarize: Callable[[RegionGroup], str],
) -> None:
connector = '└── ' if is_last else '├── '
lines.append(f'{prefix}{connector}{summarize(group)}')
@@ -354,6 +231,10 @@ def _tree_lines(
)
+# Grouping algorithm
+# ====================
+
+
def _loop_group_regions(
idx_path: list[int], constraint_path: list[str], regions: list[RegionStr]
) -> list[RegionGroup]:
@@ -386,8 +267,10 @@ def _loop_group_regions(
Invariants (across `reduce`/`loop` iterations):
- `acc['regions']` shrinks monotonically: each iteration moves regions into
`has` (grouped) or keeps them in `without` (remaining).
- - `acc['constraint_path']` grows by one element per iteration (the current
- `konstraint`), regardless of whether any regions matched.
+ - `acc['idx_path']` and `acc['constraint_path']` stay constant across
+ iterations (they describe this level). The current `konstraint` is only
+ prepended for the `has` branch's recursion, never carried into later
+ iterations, which process `without` regions that lack `konstraint`.
- `acc['groups']` only grows: new groups are prepended when `has` is non-empty.
"""
@@ -424,8 +307,10 @@ def update_counter(s: str) -> None:
return counter
counter = mk_counter(all_constraints_with_dup)
- assoc_list: list[tuple[str, int]] = [(k, v) for (k, v) in counter.items()]
- sorted(assoc_list, key=lambda kv: kv[1], reverse=True)
+ # Most frequent first, ties broken alphabetically.
+ assoc_list: list[tuple[str, int]] = sorted(
+ counter.items(), key=lambda kv: (-kv[1], kv[0])
+ )
constraints_by_most_frequent: list[str] = [kv[0] for kv in assoc_list]
# grouped: tuple[list[RegionGroup], list[RegionStr]]
@@ -489,85 +374,19 @@ def loop(
res = [group, *groups], without
else:
res = groups, without
+ # `idx_path` / `constraint_path` describe this level and must stay
+ # constant across reduce iterations. Only the `has` branch (recursed
+ # above) gets the extended `new_idx_path` / `new_constraint_path`; the
+ # `without` regions handled by later iterations do not contain
+ # `konstraint`, so it must not leak into their path.
return Acc(
groups=res[0],
regions=res[1],
- idx_path=new_idx_path,
- constraint_path=new_constraint_path,
+ idx_path=idx_path,
+ constraint_path=constraint_path,
)
init = Acc(
groups=[], regions=regions, idx_path=idx_path, constraint_path=constraint_path
)
return reduce(loop, constraints_by_most_frequent, init)['groups'][::-1]
-
-
-# YAML Dump
-# ====================
-
-
-def _region_str_representer(dumper: yaml.Dumper, data: RegionStr) -> yaml.Node:
- mapping: dict[str, object] = {}
- if data.constraints_str is not None:
- mapping['constraints'] = data.constraints_str
- if data.invariant_str is not None:
- mapping['invariant'] = data.invariant_str
- if data.model_str is not None:
- mapping['model'] = data.model_str
- if data.model_eval_str is not None:
- mapping['model_eval'] = data.model_eval_str
- return dumper.represent_mapping('!Region', mapping)
-
-
-def _region_group_representer(
- dumper: yaml.Dumper, data: RegionGroup, *, depth_limit: int | None = None
-) -> yaml.Node:
- mapping: dict[str, object] = data.to_dict()
- if data.children:
- if depth_limit is None or depth_limit <= 0:
- mapping['children'] = data.children
- return dumper.represent_mapping('!RegionGroup', mapping)
-
-
-def _indexed_region_group_representer(
- dumper: yaml.Dumper, data: IndexedRegionGroup
-) -> yaml.Node:
- mapping: dict[str, object] = {}
-
- mapping['id'] = data.id
- mapping['label_path'] = '.'.join(map(str, data.label_path))
- mapping['depth'] = data.depth
- mapping['weight'] = data.weight
-
- mapping['constraints'] = data.constraints
- mapping['introduced_constraint'] = data.constraints[-1]
- if (region := data.region) is not None:
- mapping['invariant'] = region.invariant_str
- mapping['example_input'] = region.model_str
- mapping['example_output'] = region.model_eval_str
-
- mapping['n_children_regions'] = len(data.children)
- mapping['children'] = data.children
- return dumper.represent_mapping('tag:yaml.org,2002:map', mapping)
- # return dumper.represent_mapping('!IndexedRegionGroup', mapping)
-
-
-def mk_dumper(*, depth_limit: int | None = None) -> type[yaml.Dumper]:
- class RegionDecompDumper(yaml.Dumper):
- pass
-
- RegionDecompDumper.add_representer(
- RegionStr,
- _region_str_representer,
- )
-
- def _rg_representer(dumper: yaml.Dumper, data: RegionGroup) -> yaml.Node:
- next_limit = None if depth_limit is None else depth_limit - 1
- return _region_group_representer(dumper, data, depth_limit=next_limit)
-
- RegionDecompDumper.add_representer(RegionGroup, _rg_representer)
- RegionDecompDumper.add_representer(
- IndexedRegionGroup, _indexed_region_group_representer
- )
- RegionDecompDumper.add_representer(str, str_representer)
- return RegionDecompDumper
diff --git a/packages/imandrax-api-models/tests/test_grouped_region_decomp.py b/packages/imandrax-api-models/tests/test_grouped_region_decomp.py
index 0e057cc3..f59ebadf 100644
--- a/packages/imandrax-api-models/tests/test_grouped_region_decomp.py
+++ b/packages/imandrax-api-models/tests/test_grouped_region_decomp.py
@@ -1,14 +1,18 @@
+import os
+
import imandrax_api
from imandrax_api.lib import RegionStr
from inline_snapshot import snapshot
-from imandrax_api_models.region_decomp import HumDecomposeRes, RegionGroup
-
+from imandrax_api_models.proto_models import DecomposeRes
+from imandrax_api_models.region_decomp import (
+ EnrichedDecomposeRes,
+ RegionGroup,
+ get_leaf_groups,
+)
-def trust():
- import os
- from typing import NoReturn
+def trust() -> DecomposeRes:
import dotenv
from imandrax_api_models.client import ImandraXClient
@@ -31,356 +35,92 @@ def trust():
if y > 0 then 5
else 6"""
_eval_res = c.eval_src(IML_CODE)
- decomp_res = c.decompose(name='classify')
-
- def raise_(exc: BaseException) -> NoReturn:
- raise exc
-
- regions: list[RegionStr] = (
- decomp_res.regions_str
- if (decomp_res.regions_str)
- else (raise_(ValueError('No regions')))
- )
- return regions
+ return c.decompose(name='classify', string_results=True, prune=True)
def test():
- # regions = trust()
- # assert regions == snapshot()
- regions = [
- RegionStr(
- constraints_str=['x <= 0', 'y <= 0'],
- invariant_str='6',
- model_str={'x': '0', 'y': '0'},
- model_eval_str='6',
- ),
- RegionStr(
- constraints_str=['y >= 1', 'x <= 0'],
- invariant_str='5',
- model_str={'x': '0', 'y': '1'},
- model_eval_str='5',
- ),
- RegionStr(
- constraints_str=['x >= 1', 'y >= (-10)', 'y <= 0'],
- invariant_str='4',
- model_str={'x': '1', 'y': '0'},
- model_eval_str='4',
- ),
- RegionStr(
- constraints_str=['x >= 1', 'y <= (-11)'],
- invariant_str='3',
- model_str={'x': '1', 'y': '(-11)'},
- model_eval_str='3',
- ),
- RegionStr(
- constraints_str=['x <= y', 'x >= 1', 'y >= 1'],
- invariant_str='2',
- model_str={'x': '1', 'y': '1'},
- model_eval_str='2',
- ),
- RegionStr(
- constraints_str=['not (x <= y)', 'x >= 1', 'y >= 1'],
- invariant_str='1',
- model_str={'x': '2', 'y': '1'},
- model_eval_str='1',
- ),
- ]
+ decomp_res = trust()
+ edr = EnrichedDecomposeRes.from_decomp_res(decomp_res)
- hdr = HumDecomposeRes.from_regions(regions)
- assert hdr.to_tree_str() == snapshot("""\
-├── [1] constraint='x <= 0' invariant=None (w=2, n_children=2, n_descendants=2)
-│ ├── [1.1] constraint='y <= 0' invariant='6' (w=1, n_children=0, n_descendants=0)
-│ └── [1.1.2] constraint='y >= 1' invariant='5' (w=1, n_children=0, n_descendants=0)
-├── [1.2.1.1] constraint='y >= (-10)' invariant='4' (w=1, n_children=0, n_descendants=0)
-├── [1.2.3] constraint='y >= 1' invariant=None (w=2, n_children=2, n_descendants=2)
-│ ├── [1.2.3.1.1] constraint='x >= 1' invariant='2' (w=1, n_children=0, n_descendants=0)
-│ └── [1.2.3.1.2.1] constraint='not (x <= y)' invariant='1' (w=1, n_children=0, n_descendants=0)
-└── [1.2.3.4.1] constraint='y <= (-11)' invariant='3' (w=1, n_children=0, n_descendants=0)\
-""")
- hdr_dict_hierarchy = hdr.to_dict_hierarchical()
- hdr_dict_flat = hdr.to_dict_flat()
- label_path_n_children_map_flat: list[tuple[str, int]] = [
- ('.'.join(map(str, irg.label_path)), len(irg.children))
- for irg in hdr_dict_flat['region_groups']
- ]
+ assert edr.regions_str
+ leaf_groups = get_leaf_groups(edr.region_groups)
+ assert len(leaf_groups) == len(edr.regions_str)
+ for leaf_group in leaf_groups:
+ assert len(leaf_group.children) == 0
+ assert leaf_group.region, 'Leaf group must be concrete'
+ assert leaf_group.region.constraints_str
+ assert set(leaf_group.constraints) == set(leaf_group.region.constraints_str)
- # ::: test-child-count
- def _collect_hierarchy(groups: list[RegionGroup]) -> list[tuple[str, int]]:
- result: list[tuple[str, int]] = []
- for rg in groups:
- result.append(('.'.join(map(str, rg.label_path)), len(rg.children)))
- if rg.children:
- result.extend(_collect_hierarchy(rg.children))
- return result
-
- label_path_n_children_map_hierarchy: list[tuple[str, int]] = _collect_hierarchy(
- hdr_dict_hierarchy['region_groups']
- )
- assert sorted(label_path_n_children_map_flat) == sorted(
- label_path_n_children_map_hierarchy
+ assert edr.regions_str == snapshot(
+ [
+ RegionStr(
+ constraints_str=['y <= 0', 'x <= 0'],
+ invariant_str='6',
+ model_str={'x': '0', 'y': '0'},
+ model_eval_str='6',
+ ),
+ RegionStr(
+ constraints_str=['y >= 1', 'x <= 0'],
+ invariant_str='5',
+ model_str={'x': '0', 'y': '1'},
+ model_eval_str='5',
+ ),
+ RegionStr(
+ constraints_str=['y >= (-10)', 'y <= 0', 'x >= 1'],
+ invariant_str='4',
+ model_str={'x': '1', 'y': '0'},
+ model_eval_str='4',
+ ),
+ RegionStr(
+ constraints_str=['y <= (-11)', 'x >= 1'],
+ invariant_str='3',
+ model_str={'x': '1', 'y': '(-11)'},
+ model_eval_str='3',
+ ),
+ RegionStr(
+ constraints_str=['x <= y', 'y >= 1', 'x >= 1'],
+ invariant_str='2',
+ model_str={'x': '1', 'y': '1'},
+ model_eval_str='2',
+ ),
+ RegionStr(
+ constraints_str=['x > y', 'y >= 1', 'x >= 1'],
+ invariant_str='1',
+ model_str={'x': '2', 'y': '1'},
+ model_eval_str='1',
+ ),
+ ]
)
- # :::
- assert (hdr.dumper_func()(hdr_dict_hierarchy)) == snapshot("""\
-summary:
- n_regions: 6
-region_groups:
-- !RegionGroup
- label_path: '1'
- constraints:
- - x <= 0
- introduced_constraint: x <= 0
- weight: 2
- n_children_regions: 2
- n_descendant_regions: 2
- n_leaf_regions: 2
- children:
- - !RegionGroup
- label_path: '1.1'
- constraints:
- - x <= 0
- - y <= 0
- introduced_constraint: y <= 0
- weight: 1
- n_children_regions: 0
- n_descendant_regions: 0
- n_leaf_regions: 1
- invariant: '6'
- example_input:
- x: '0'
- y: '0'
- example_output: '6'
- - !RegionGroup
- label_path: 1.1.2
- constraints:
- - x <= 0
- - y <= 0
- - y >= 1
- introduced_constraint: y >= 1
- weight: 1
- n_children_regions: 0
- n_descendant_regions: 0
- n_leaf_regions: 1
- invariant: '5'
- example_input:
- x: '0'
- y: '1'
- example_output: '5'
-- !RegionGroup
- label_path: 1.2.1.1
- constraints:
- - x <= 0
- - y <= 0
- - x >= 1
- - y >= (-10)
- introduced_constraint: y >= (-10)
- weight: 1
- n_children_regions: 0
- n_descendant_regions: 0
- n_leaf_regions: 1
- invariant: '4'
- example_input:
- x: '1'
- y: '0'
- example_output: '4'
-- !RegionGroup
- label_path: 1.2.3
- constraints:
- - x <= 0
- - y <= 0
- - y >= 1
- introduced_constraint: y >= 1
- weight: 2
- n_children_regions: 2
- n_descendant_regions: 2
- n_leaf_regions: 2
- children:
- - !RegionGroup
- label_path: 1.2.3.1.1
- constraints:
- - x <= 0
- - y <= 0
- - y >= 1
- - x <= y
- - x >= 1
- introduced_constraint: x >= 1
- weight: 1
- n_children_regions: 0
- n_descendant_regions: 0
- n_leaf_regions: 1
- invariant: '2'
- example_input:
- x: '1'
- y: '1'
- example_output: '2'
- - !RegionGroup
- label_path: 1.2.3.1.2.1
- constraints:
- - x <= 0
- - y <= 0
- - y >= 1
- - x <= y
- - x >= 1
- - not (x <= y)
- introduced_constraint: not (x <= y)
- weight: 1
- n_children_regions: 0
- n_descendant_regions: 0
- n_leaf_regions: 1
- invariant: '1'
- example_input:
- x: '2'
- y: '1'
- example_output: '1'
-- !RegionGroup
- label_path: 1.2.3.4.1
- constraints:
- - x <= 0
- - y <= 0
- - y >= 1
- - x >= 1
- - y <= (-11)
- introduced_constraint: y <= (-11)
- weight: 1
- n_children_regions: 0
- n_descendant_regions: 0
- n_leaf_regions: 1
- invariant: '3'
- example_input:
- x: '1'
- y: (-11)
- example_output: '3'
+ # region_groups is auto-populated on validation from regions_str.
+ assert edr.to_tree_str() == snapshot("""\
+├── [1] new_constraint='x >= 1' n_leaf_regions=4
+│ ├── [1.1] new_constraint='y >= 1' n_leaf_regions=2
+│ │ ├── [1.1.1] new_constraint='x <= y' invariant='2' is_leaf=True
+│ │ └── [1.1.2] new_constraint='x > y' invariant='1' is_leaf=True
+│ ├── [1.2] new_constraint='y <= (-11)' invariant='3' is_leaf=True
+│ └── [1.3.1] new_constraint='y >= (-10)' invariant='4' is_leaf=True
+├── [2.1] new_constraint='x <= 0' invariant='5' is_leaf=True
+└── [3.1] new_constraint='y <= 0' invariant='6' is_leaf=True\
""")
- assert (hdr.dumper_func()(hdr_dict_flat)) == snapshot("""\
-summary:
- n_regions: 6
-region_groups:
-- id: '0'
- label_path: '1'
- depth: 1
- weight: 2
- constraints:
- - x <= 0
- introduced_constraint: x <= 0
- n_children_regions: 2
- children:
- - '4'
- - '5'
-- id: '4'
- label_path: '1.1'
- depth: 2
- weight: 1
- constraints:
- - x <= 0
- - y <= 0
- introduced_constraint: y <= 0
- invariant: '6'
- example_input:
- x: '0'
- y: '0'
- example_output: '6'
- n_children_regions: 0
- children: []
-- id: '5'
- label_path: 1.1.2
- depth: 2
- weight: 1
- constraints:
- - x <= 0
- - y <= 0
- - y >= 1
- introduced_constraint: y >= 1
- invariant: '5'
- example_input:
- x: '0'
- y: '1'
- example_output: '5'
- n_children_regions: 0
- children: []
-- id: '1'
- label_path: 1.2.1.1
- depth: 1
- weight: 1
- constraints:
- - x <= 0
- - y <= 0
- - x >= 1
- - y >= (-10)
- introduced_constraint: y >= (-10)
- invariant: '4'
- example_input:
- x: '1'
- y: '0'
- example_output: '4'
- n_children_regions: 0
- children: []
-- id: '2'
- label_path: 1.2.3
- depth: 1
- weight: 2
- constraints:
- - x <= 0
- - y <= 0
- - y >= 1
- introduced_constraint: y >= 1
- n_children_regions: 2
- children:
- - '6'
- - '7'
-- id: '6'
- label_path: 1.2.3.1.1
- depth: 2
- weight: 1
- constraints:
- - x <= 0
- - y <= 0
- - y >= 1
- - x <= y
- - x >= 1
- introduced_constraint: x >= 1
- invariant: '2'
- example_input:
- x: '1'
- y: '1'
- example_output: '2'
- n_children_regions: 0
- children: []
-- id: '7'
- label_path: 1.2.3.1.2.1
- depth: 2
- weight: 1
- constraints:
- - x <= 0
- - y <= 0
- - y >= 1
- - x <= y
- - x >= 1
- - not (x <= y)
- introduced_constraint: not (x <= y)
- invariant: '1'
- example_input:
- x: '2'
- y: '1'
- example_output: '1'
- n_children_regions: 0
- children: []
-- id: '3'
- label_path: 1.2.3.4.1
- depth: 1
- weight: 1
- constraints:
- - x <= 0
- - y <= 0
- - y >= 1
- - x >= 1
- - y <= (-11)
- introduced_constraint: y <= (-11)
- invariant: '3'
- example_input:
- x: '1'
- y: (-11)
- example_output: '3'
- n_children_regions: 0
- children: []
-""")
+ # (label_path, full constraint path) for every node, depth-first.
+ def _walk(groups: list[RegionGroup]) -> list[tuple[str, list[str]]]:
+ out: list[tuple[str, list[str]]] = []
+ for g in groups:
+ out.append(('.'.join(map(str, g.label_path)), g.constraints))
+ out.extend(_walk(g.children))
+ return out
+
+ assert _walk(edr.region_groups) == snapshot(
+ [
+ ('1', ['x >= 1']),
+ ('1.1', ['x >= 1', 'y >= 1']),
+ ('1.1.1', ['x >= 1', 'y >= 1', 'x <= y']),
+ ('1.1.2', ['x >= 1', 'y >= 1', 'x > y']),
+ ('1.2', ['x >= 1', 'y <= (-11)']),
+ ('1.3.1', ['x >= 1', 'y <= 0', 'y >= (-10)']),
+ ('2.1', ['y >= 1', 'x <= 0']),
+ ('3.1', ['x <= 0', 'y <= 0']),
+ ]
+ )