feat(layout): implement deterministic 2xK grid engine with single decision table
- Change N=1->2 split policy from down to right to establish 2 full-height columns - Share single decision table across GUI and headless modes - Add _full_height_pane guard to prevent half-height column splits - Quadruple-wire default max_columns=2 and max_rows=2 across layout.py, lib.sh, and .mam.env.example - Update unit and integration tests to enforce deterministic 2x2 grid trajectory
This commit is contained in:
@@ -432,7 +432,7 @@ except Exception:
|
||||
split_dir=""
|
||||
if [ -n "$sample_pane" ]; then
|
||||
layout_raw=$(_real_herdr pane layout --pane "$sample_pane" 2>/dev/null || echo "")
|
||||
read -r split_dir split_target < <(printf '%s' "$layout_raw" | python3 -m lib_py.layout --min-cols "${MAM_MIN_PANE_COLS:-15}" --min-rows "${MAM_MIN_PANE_ROWS:-0}" --sample-pane "$sample_pane" 2>/dev/null || echo "right $sample_pane")
|
||||
read -r split_dir split_target < <(printf '%s' "$layout_raw" | python3 -m lib_py.layout --min-cols "${MAM_MIN_PANE_COLS:-15}" --min-rows "${MAM_MIN_PANE_ROWS:-0}" --max-cols "${MAM_MAX_PANE_COLS:-2}" --max-rows "${MAM_MAX_PANE_ROWS:-2}" --sample-pane "$sample_pane" 2>/dev/null || echo "right $sample_pane")
|
||||
split_dir="${split_dir:-right}"
|
||||
sample_pane="${split_target:-$sample_pane}"
|
||||
fi
|
||||
|
||||
+125
-91
@@ -68,108 +68,140 @@ def extract_panes(data: Dict[str, Any]) -> List[PaneInfo]:
|
||||
return panes
|
||||
|
||||
|
||||
def _pane_id(pane: Any) -> str:
|
||||
if isinstance(pane, PaneInfo):
|
||||
return pane.pane_id
|
||||
return str(pane or "")
|
||||
|
||||
|
||||
def _right(reason: str, pane: Any) -> LayoutDecision:
|
||||
return LayoutDecision(target_pane_id=_pane_id(pane), direction="right", reason=reason)
|
||||
|
||||
|
||||
def _down(reason: str, pane: Any) -> LayoutDecision:
|
||||
return LayoutDecision(target_pane_id=_pane_id(pane), direction="down", reason=reason)
|
||||
|
||||
|
||||
def _overflow(reason: str, pane: Any) -> LayoutDecision:
|
||||
return LayoutDecision(
|
||||
target_pane_id=_pane_id(pane), direction="overflow", is_overflow=True, reason=reason
|
||||
)
|
||||
|
||||
|
||||
def _full_height_pane(col: List[PaneInfo], area_h: int, tol: int = 2) -> Optional[PaneInfo]:
|
||||
"""Single pane that occupies the full column height. Else None (R-2)."""
|
||||
if len(col) != 1:
|
||||
return None
|
||||
if area_h <= 0:
|
||||
return col[0]
|
||||
return col[0] if abs(col[0].height - area_h) <= tol else None
|
||||
|
||||
|
||||
def _group_columns(panes: List[PaneInfo]) -> List[List[PaneInfo]]:
|
||||
columns: List[List[PaneInfo]] = []
|
||||
for p in sorted(panes, key=lambda q: (q.x, q.y)):
|
||||
matched = False
|
||||
for col in columns:
|
||||
if abs(col[0].x - p.x) <= 2:
|
||||
col.append(p)
|
||||
matched = True
|
||||
break
|
||||
if not matched:
|
||||
columns.append([p])
|
||||
for col in columns:
|
||||
col.sort(key=lambda q: q.y)
|
||||
return columns
|
||||
|
||||
|
||||
def _area_height(data: Dict[str, Any], panes: List[PaneInfo]) -> int:
|
||||
res = data.get("result") if isinstance(data, dict) else {}
|
||||
if not isinstance(res, dict):
|
||||
res = {}
|
||||
layout = res.get("layout")
|
||||
if isinstance(layout, dict):
|
||||
area = layout.get("area")
|
||||
if isinstance(area, dict) and area.get("height"):
|
||||
try:
|
||||
return int(area["height"])
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
if not panes:
|
||||
return 0
|
||||
return max(p.y + p.height for p in panes)
|
||||
|
||||
|
||||
def _decide(
|
||||
cols: List[List[PaneInfo]],
|
||||
area_h: int,
|
||||
max_columns: int,
|
||||
max_rows: int,
|
||||
min_cols: int,
|
||||
min_rows: int,
|
||||
) -> LayoutDecision:
|
||||
"""Shared decision table (GUI observation)."""
|
||||
if not cols:
|
||||
return _right("no_panes_default", "")
|
||||
|
||||
# ① New column only from a full-height pane (R-2).
|
||||
if len(cols) < max_columns:
|
||||
fh = _full_height_pane(cols[-1], area_h)
|
||||
if fh is not None:
|
||||
if fh.width > 0 and fh.width // 2 < min_cols:
|
||||
return _overflow("column_width_overflow", fh)
|
||||
return _right("new_column_right", fh)
|
||||
|
||||
# ② Fill the shortest column (leftmost on ties).
|
||||
shortest = min(cols, key=lambda c: (len(c), c[0].x))
|
||||
if len(shortest) < max_rows:
|
||||
bottom = shortest[-1]
|
||||
if min_rows > 0 and bottom.height > 0 and bottom.height // 2 < min_rows:
|
||||
return _overflow("row_height_overflow", bottom)
|
||||
return _down("fill_column", bottom)
|
||||
|
||||
# ③ Capacity reached.
|
||||
return _overflow("grid_capacity_reached", cols[-1][0])
|
||||
|
||||
|
||||
def _decide_headless(
|
||||
panes: List[PaneInfo], max_columns: int, max_rows: int
|
||||
) -> LayoutDecision:
|
||||
"""Same table as _decide, observed from creation order (0x0 panes)."""
|
||||
n = len(panes)
|
||||
if n >= max_columns * max_rows:
|
||||
return _overflow("grid_capacity_reached", panes[-1])
|
||||
if n < max_columns:
|
||||
return _right("new_column_right", panes[n - 1])
|
||||
return _down("fill_column", panes[n - max_columns])
|
||||
|
||||
|
||||
def compute_2xk_layout(
|
||||
data: Dict[str, Any],
|
||||
min_cols: int = 15,
|
||||
min_rows: int = 0,
|
||||
max_columns: Optional[int] = None,
|
||||
max_columns: Optional[int] = 2,
|
||||
max_rows: Optional[int] = 2,
|
||||
default_anchor_id: Optional[str] = None
|
||||
) -> LayoutDecision:
|
||||
"""Balanced 2xK grid. Herdr-supported splits only: 'right' and 'down'.
|
||||
|
||||
GUI and headless share one decision table. Observation differs:
|
||||
geometry grouping vs creation-order column occupancy.
|
||||
"""
|
||||
Computes optimal target pane and direction to maintain a balanced 2xK grid.
|
||||
Only uses Herdr-supported split directions: 'right' and 'down'.
|
||||
"""
|
||||
if max_columns is None:
|
||||
max_columns = 2
|
||||
if max_rows is None:
|
||||
max_rows = 2
|
||||
|
||||
panes = extract_panes(data)
|
||||
|
||||
if not panes:
|
||||
# no_panes_default: when herdr returns no panes (empty workspace), default to 'right'
|
||||
target = default_anchor_id or ""
|
||||
return LayoutDecision(target_pane_id=target, direction="right", is_overflow=False, reason="no_panes_default")
|
||||
return _right("no_panes_default", default_anchor_id or "")
|
||||
|
||||
# If only 1 pane in workspace
|
||||
if len(panes) == 1:
|
||||
p = panes[0]
|
||||
# In 2xK grid, 1 pane -> 2 panes: split down to create top and bottom rows
|
||||
# Check height overflow only if min_rows > 0 is explicitly configured
|
||||
if min_rows > 0 and p.height > 0 and p.height // 2 < min_rows:
|
||||
# If height is too small for 2 rows, try splitting right if width allows
|
||||
if p.width > 0 and p.width // 2 >= min_cols:
|
||||
return LayoutDecision(target_pane_id=p.pane_id, direction="right", reason="single_pane_height_constrained")
|
||||
elif p.width > 0 and p.width // 2 < min_cols:
|
||||
return LayoutDecision(target_pane_id=p.pane_id, direction="overflow", is_overflow=True, reason="single_pane_overflow")
|
||||
else:
|
||||
return LayoutDecision(target_pane_id=p.pane_id, direction="right", reason="single_pane_height_constrained_unknown_width")
|
||||
return LayoutDecision(target_pane_id=p.pane_id, direction="down", reason="single_pane_split_down")
|
||||
if all(p.width <= 0 or p.height <= 0 for p in panes):
|
||||
return _decide_headless(panes, max_columns, max_rows)
|
||||
|
||||
# Check for Headless mode: all panes have width <= 0 or height <= 0
|
||||
is_headless = all(p.width <= 0 or p.height <= 0 for p in panes)
|
||||
if is_headless:
|
||||
# Headless panes are all 0x0, so columns cannot be counted from geometry
|
||||
# the way the GUI path does. The alternation below (odd -> down,
|
||||
# even -> right) is what builds the grid, so while that invariant holds
|
||||
# the completed-column count is exactly n // 2. If panes were closed and
|
||||
# the shape drifted, an odd n is absorbed by the `down` branch and the
|
||||
# estimate self-corrects at the next even n.
|
||||
n = len(panes)
|
||||
anchor = default_anchor_id or panes[-1].pane_id
|
||||
if n % 2 == 1:
|
||||
# Filling an existing column never opens a new one, so max_columns is
|
||||
# deliberately NOT checked here -- this mirrors the GUI path, where
|
||||
# `fill_singleton_column` also ignores the cap. max_columns is a
|
||||
# growth guard, not an invariant over the existing layout.
|
||||
return LayoutDecision(target_pane_id=anchor, direction="down", reason="headless_odd_down")
|
||||
current_cols = n // 2
|
||||
if max_columns and current_cols >= max_columns:
|
||||
return LayoutDecision(target_pane_id=anchor, direction="overflow",
|
||||
is_overflow=True, reason="max_columns_reached")
|
||||
return LayoutDecision(target_pane_id=anchor, direction="right", reason="headless_even_right")
|
||||
|
||||
# Geometry-aware column grouping
|
||||
# Group panes into columns by X coordinate (fuzz threshold 2 cols)
|
||||
sorted_by_x = sorted(panes, key=lambda p: (p.x, p.y))
|
||||
columns: List[List[PaneInfo]] = []
|
||||
for p in sorted_by_x:
|
||||
matched_col = False
|
||||
for col in columns:
|
||||
if abs(col[0].x - p.x) <= 2:
|
||||
col.append(p)
|
||||
matched_col = True
|
||||
break
|
||||
if not matched_col:
|
||||
columns.append([p])
|
||||
|
||||
# Sort each column's panes by Y coordinate (top to bottom)
|
||||
for col in columns:
|
||||
col.sort(key=lambda p: p.y)
|
||||
|
||||
num_cols = len(columns)
|
||||
|
||||
# 1. Check for any singleton column (column with only 1 pane spanning full height)
|
||||
singleton_col = None
|
||||
for col in columns:
|
||||
if len(col) == 1:
|
||||
singleton_col = col
|
||||
break
|
||||
|
||||
if singleton_col is not None:
|
||||
target_p = singleton_col[0]
|
||||
# Check height only if min_rows > 0
|
||||
if min_rows > 0 and target_p.height > 0 and target_p.height // 2 < min_rows:
|
||||
return LayoutDecision(target_pane_id=target_p.pane_id, direction="overflow", is_overflow=True, reason="singleton_height_overflow")
|
||||
return LayoutDecision(target_pane_id=target_p.pane_id, direction="down", reason="fill_singleton_column")
|
||||
|
||||
# 2. All existing columns have 2 (or more) panes -> we need to start a NEW column to the right
|
||||
if max_columns and num_cols >= max_columns:
|
||||
return LayoutDecision(target_pane_id=columns[-1][0].pane_id, direction="overflow", is_overflow=True, reason="max_columns_reached")
|
||||
|
||||
# Target the top pane of the rightmost column to split right
|
||||
rightmost_top_pane = columns[-1][0]
|
||||
|
||||
# Check width constraint on the rightmost column
|
||||
if rightmost_top_pane.width > 0 and rightmost_top_pane.width // 2 < min_cols:
|
||||
return LayoutDecision(target_pane_id=rightmost_top_pane.pane_id, direction="overflow", is_overflow=True, reason="column_width_overflow")
|
||||
|
||||
return LayoutDecision(target_pane_id=rightmost_top_pane.pane_id, direction="right", reason="new_column_right")
|
||||
cols = _group_columns(panes)
|
||||
return _decide(cols, _area_height(data, panes), max_columns, max_rows, min_cols, min_rows)
|
||||
|
||||
|
||||
def _env_int(*names: str, default: Optional[int] = None) -> Optional[int]:
|
||||
@@ -200,7 +232,8 @@ def main():
|
||||
parser = argparse.ArgumentParser(description="Compute 2xK grid TUI layout split direction")
|
||||
parser.add_argument("--min-cols", type=int, default=_env_int("MAM_MIN_COLS", "MAM_MIN_PANE_COLS", default=15))
|
||||
parser.add_argument("--min-rows", type=int, default=_env_int("MAM_MIN_ROWS", "MAM_MIN_PANE_ROWS", default=0))
|
||||
parser.add_argument("--max-cols", type=int, default=_env_int("MAM_MAX_COLS", "MAM_MAX_PANE_COLS"))
|
||||
parser.add_argument("--max-cols", type=int, default=_env_int("MAM_MAX_COLS", "MAM_MAX_PANE_COLS", default=2))
|
||||
parser.add_argument("--max-rows", type=int, default=_env_int("MAM_MAX_ROWS", "MAM_MAX_PANE_ROWS", default=2))
|
||||
parser.add_argument("--sample-pane", type=str, default=None)
|
||||
parser.add_argument("--json", action="store_true", help="Output full JSON decision")
|
||||
|
||||
@@ -219,6 +252,7 @@ def main():
|
||||
min_cols=args.min_cols,
|
||||
min_rows=args.min_rows,
|
||||
max_columns=args.max_cols,
|
||||
max_rows=args.max_rows,
|
||||
default_anchor_id=args.sample_pane
|
||||
)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user