matchlab 0.1.0.dev1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- matchlab/__init__.py +55 -0
- matchlab/adapters/__init__.py +24 -0
- matchlab/adapters/base.py +446 -0
- matchlab/adapters/duckdb.py +659 -0
- matchlab/cli.py +177 -0
- matchlab/core/__init__.py +1 -0
- matchlab/core/dataframes.py +254 -0
- matchlab/core/dsu.py +62 -0
- matchlab/core/exceptions.py +40 -0
- matchlab/core/hash.py +141 -0
- matchlab/core/kinds.py +12 -0
- matchlab/core/logging.py +190 -0
- matchlab/core/resolver_output.py +173 -0
- matchlab/core/schemas.py +82 -0
- matchlab/core/sql.py +12 -0
- matchlab/document.py +329 -0
- matchlab/eval/__init__.py +11 -0
- matchlab/eval/judgements.py +41 -0
- matchlab/eval/metrics.py +244 -0
- matchlab/eval/samples.py +415 -0
- matchlab/eval/tui/__init__.py +66 -0
- matchlab/eval/tui/app.py +418 -0
- matchlab/eval/tui/modals.py +84 -0
- matchlab/eval/tui/styles.tcss +184 -0
- matchlab/eval/tui/widgets/__init__.py +1 -0
- matchlab/eval/tui/widgets/assignment.py +72 -0
- matchlab/eval/tui/widgets/styling.py +75 -0
- matchlab/eval/tui/widgets/table.py +285 -0
- matchlab/lineage.py +239 -0
- matchlab/locations.py +347 -0
- matchlab/models/__init__.py +10 -0
- matchlab/models/comparison.py +67 -0
- matchlab/models/dedupers/__init__.py +5 -0
- matchlab/models/dedupers/base.py +38 -0
- matchlab/models/dedupers/naive.py +77 -0
- matchlab/models/linkers/__init__.py +9 -0
- matchlab/models/linkers/base.py +41 -0
- matchlab/models/linkers/deterministic.py +229 -0
- matchlab/models/linkers/splinklinker.py +262 -0
- matchlab/models/linkers/weighteddeterministic.py +143 -0
- matchlab/models/models.py +198 -0
- matchlab/progress.py +455 -0
- matchlab/recordstep.py +181 -0
- matchlab/resolvers/__init__.py +10 -0
- matchlab/resolvers/base.py +63 -0
- matchlab/resolvers/components.py +66 -0
- matchlab/resolvers/resolvers.py +409 -0
- matchlab/sources.py +291 -0
- matchlab/specs.py +129 -0
- matchlab/steps.py +284 -0
- matchlab/testkit/__init__.py +104 -0
- matchlab/testkit/_generate.py +240 -0
- matchlab/testkit/compare.py +178 -0
- matchlab/testkit/entities.py +290 -0
- matchlab/testkit/features.py +146 -0
- matchlab/testkit/linked.py +527 -0
- matchlab/testkit/matchers.py +233 -0
- matchlab/testkit/models.py +165 -0
- matchlab/testkit/sources.py +327 -0
- matchlab/transformers/__init__.py +24 -0
- matchlab/transformers/base.py +41 -0
- matchlab/transformers/clean.py +48 -0
- matchlab/transformers/explode.py +67 -0
- matchlab/transformers/group.py +56 -0
- matchlab/transformers/select.py +31 -0
- matchlab/transformers/transform.py +101 -0
- matchlab-0.1.0.dev1.dist-info/METADATA +88 -0
- matchlab-0.1.0.dev1.dist-info/RECORD +72 -0
- matchlab-0.1.0.dev1.dist-info/WHEEL +5 -0
- matchlab-0.1.0.dev1.dist-info/entry_points.txt +2 -0
- matchlab-0.1.0.dev1.dist-info/licenses/LICENSE +21 -0
- matchlab-0.1.0.dev1.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""AssignmentBar widget for displaying column assignments in the evaluation UI."""
|
|
2
|
+
|
|
3
|
+
from typing import Any, NamedTuple
|
|
4
|
+
|
|
5
|
+
from textual.widgets import Static
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class GroupSelect(NamedTuple):
|
|
9
|
+
"""A letter and colour combination representing a group assignment."""
|
|
10
|
+
|
|
11
|
+
letter: str
|
|
12
|
+
colour: str
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class AssignmentBar(Static):
|
|
16
|
+
"""A status bar widget showing column assignments as a visual bar.
|
|
17
|
+
|
|
18
|
+
Each position represents a column in the comparison table. Positions can be:
|
|
19
|
+
|
|
20
|
+
- None (unassigned): displayed as dim •
|
|
21
|
+
- GroupSelect: displayed as letter (first occurrence) or • (subsequent blocks)
|
|
22
|
+
|
|
23
|
+
Consecutive positions with the same group assignment are rendered in that
|
|
24
|
+
group's colour, showing the letter only at the first position.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
def __init__(self, **kwargs: Any) -> None:
|
|
28
|
+
"""Initialise an empty AssignmentBar."""
|
|
29
|
+
super().__init__(**kwargs)
|
|
30
|
+
self.positions: list[GroupSelect | None] = []
|
|
31
|
+
self._render_bar()
|
|
32
|
+
|
|
33
|
+
def reset(self, num_positions: int) -> None:
|
|
34
|
+
"""Reset the bar with a new number of positions.
|
|
35
|
+
|
|
36
|
+
Args:
|
|
37
|
+
num_positions: The number of columns to represent.
|
|
38
|
+
"""
|
|
39
|
+
self.positions = [None] * num_positions
|
|
40
|
+
self._render_bar()
|
|
41
|
+
|
|
42
|
+
def set_position(self, index: int, letter: str, colour: str) -> None:
|
|
43
|
+
"""Set a position to a letter and colour.
|
|
44
|
+
|
|
45
|
+
Args:
|
|
46
|
+
index: The position index to set.
|
|
47
|
+
letter: The group letter (a-z).
|
|
48
|
+
colour: The colour name for the group.
|
|
49
|
+
"""
|
|
50
|
+
if 0 <= index < len(self.positions):
|
|
51
|
+
self.positions[index] = GroupSelect(letter, colour)
|
|
52
|
+
self._render_bar()
|
|
53
|
+
|
|
54
|
+
def _render_bar(self) -> None:
|
|
55
|
+
"""Generate the Rich markup for the assignment bar."""
|
|
56
|
+
if not self.positions:
|
|
57
|
+
self.update("")
|
|
58
|
+
return
|
|
59
|
+
|
|
60
|
+
parts = []
|
|
61
|
+
for i, pos in enumerate(self.positions):
|
|
62
|
+
colour = "dim" if pos is None else pos.colour
|
|
63
|
+
|
|
64
|
+
# Show the letter only where a block starts, otherwise •
|
|
65
|
+
if pos is None or (i > 0 and self.positions[i - 1] == pos):
|
|
66
|
+
char = "•"
|
|
67
|
+
else:
|
|
68
|
+
char = pos.letter
|
|
69
|
+
|
|
70
|
+
parts.append(f"[{colour}]{char}[/{colour}]")
|
|
71
|
+
|
|
72
|
+
self.update("".join(parts))
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""Styling utilities for entity resolution evaluation UI."""
|
|
2
|
+
|
|
3
|
+
GROUP_STYLES = {
|
|
4
|
+
# Row: q w e r t y u i o p
|
|
5
|
+
"q": ("■", "#e53935"), # red
|
|
6
|
+
"w": ("●", "#43a047"), # green
|
|
7
|
+
"e": ("▲", "#1e88e5"), # blue
|
|
8
|
+
"r": ("◆", "#fdd835"), # yellow
|
|
9
|
+
"t": ("★", "#d81b60"), # magenta
|
|
10
|
+
"y": ("⬢", "#00acc1"), # cyan
|
|
11
|
+
"u": ("♦", "#fb8c00"), # orange
|
|
12
|
+
"i": ("▼", "#00897b"), # teal
|
|
13
|
+
"o": ("○", "#8e24aa"), # purple
|
|
14
|
+
"p": ("△", "#6d4c41"), # brown
|
|
15
|
+
# Row: a s d f g h j k l
|
|
16
|
+
"a": ("◇", "#d81b60"), # magenta
|
|
17
|
+
"s": ("☆", "#00acc1"), # cyan
|
|
18
|
+
"d": ("⬡", "#e53935"), # red
|
|
19
|
+
"f": ("✦", "#43a047"), # green
|
|
20
|
+
"g": ("✧", "#1e88e5"), # blue
|
|
21
|
+
"h": ("⟐", "#fdd835"), # yellow
|
|
22
|
+
"j": ("✚", "#d81b60"), # magenta
|
|
23
|
+
"k": ("✖", "#3949ab"), # indigo (keeps distance from teal above-left)
|
|
24
|
+
"l": ("□", "#e53935"), # red
|
|
25
|
+
# Row: z x c v b n m
|
|
26
|
+
"z": ("▽", "#fb8c00"), # orange
|
|
27
|
+
"x": ("◯", "#8e24aa"), # purple (far from cyan/red above)
|
|
28
|
+
"c": ("◻", "#fdd835"), # yellow
|
|
29
|
+
"v": ("◼", "#6d4c41"), # brown
|
|
30
|
+
"b": ("⬤", "#e53935"), # red
|
|
31
|
+
"n": ("☑", "#43a047"), # green
|
|
32
|
+
"m": ("✤", "#1e88e5"), # blue
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def get_group_style(group: str) -> tuple[str, str]:
|
|
37
|
+
"""Get symbol and colour for a group letter.
|
|
38
|
+
|
|
39
|
+
Args:
|
|
40
|
+
group: Single letter group identifier (a-z)
|
|
41
|
+
|
|
42
|
+
Returns:
|
|
43
|
+
Tuple of (symbol, colour)
|
|
44
|
+
"""
|
|
45
|
+
return GROUP_STYLES.get(group.lower(), ("■", "white"))
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def get_display_text(group: str, count: int) -> tuple[str, str]:
|
|
49
|
+
"""Get formatted display text with colour and symbol.
|
|
50
|
+
|
|
51
|
+
Args:
|
|
52
|
+
group: Single letter group identifier (a-z) or "unassigned"
|
|
53
|
+
count: Number of items in this group
|
|
54
|
+
|
|
55
|
+
Returns:
|
|
56
|
+
Tuple of (formatted_text, colour)
|
|
57
|
+
"""
|
|
58
|
+
if group == "unassigned":
|
|
59
|
+
return f"UNASSIGNED ({count})", "dim"
|
|
60
|
+
|
|
61
|
+
symbol, colour = get_group_style(group)
|
|
62
|
+
text = f"{symbol} {group.upper()} ({count})"
|
|
63
|
+
return text, colour
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def generate_css_classes() -> str:
|
|
67
|
+
"""Generate CSS classes for all groups.
|
|
68
|
+
|
|
69
|
+
Returns:
|
|
70
|
+
CSS string with all group styling classes
|
|
71
|
+
"""
|
|
72
|
+
lines = []
|
|
73
|
+
for group, (_symbol, colour) in GROUP_STYLES.items():
|
|
74
|
+
lines.append(f"ComparisonTable .group-{group} {{ background: {colour}; }}")
|
|
75
|
+
return "\n".join(lines)
|
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
"""Comparison display table for entity resolution evaluation."""
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
import polars as pl
|
|
6
|
+
from rich.text import Text
|
|
7
|
+
from textual import events
|
|
8
|
+
from textual.binding import Binding
|
|
9
|
+
from textual.message import Message
|
|
10
|
+
from textual.reactive import reactive
|
|
11
|
+
from textual.widgets import DataTable
|
|
12
|
+
from textual.widgets.data_table import ColumnKey
|
|
13
|
+
|
|
14
|
+
from matchlab.eval.samples import EvaluationItem
|
|
15
|
+
from matchlab.eval.tui.widgets.styling import get_group_style
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class ComparisonDisplayTable(DataTable):
|
|
19
|
+
"""DataTable for comparing records with keyboard-driven assignment.
|
|
20
|
+
|
|
21
|
+
The DataTable's own cursor column is the anchor for the visible "1"-"9" labels,
|
|
22
|
+
even though the cursor itself is hidden:
|
|
23
|
+
|
|
24
|
+
- The cursor column is labelled "1", the next column "2", and so on.
|
|
25
|
+
- Paging right or left moves the cursor by 9 columns, and forces a scroll so the
|
|
26
|
+
anchor column sits at the left edge, keeping the labels visible.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
current_item: reactive[EvaluationItem | None] = reactive(None)
|
|
30
|
+
current_assignments: reactive[dict[int, str]] = reactive({}, init=False)
|
|
31
|
+
current_group: reactive[str] = reactive("")
|
|
32
|
+
table_ready: reactive[bool] = reactive(False)
|
|
33
|
+
|
|
34
|
+
# DataTable overrides
|
|
35
|
+
BINDINGS = [
|
|
36
|
+
Binding("up", "page_up", "Page up", show=False),
|
|
37
|
+
Binding("down", "page_down", "Page down", show=False),
|
|
38
|
+
Binding("right", "page_right", "Page right", show=False),
|
|
39
|
+
Binding("left", "page_left", "Page left", show=False),
|
|
40
|
+
]
|
|
41
|
+
|
|
42
|
+
def __init__(
|
|
43
|
+
self, scroll_debounce_delay: float | None = 0.3, **kwargs: Any
|
|
44
|
+
) -> None:
|
|
45
|
+
"""Initialise the comparison display table."""
|
|
46
|
+
super().__init__(**kwargs)
|
|
47
|
+
self.zebra_stripes = True
|
|
48
|
+
# The cursor tracks columns, for the anchor logic below, but stays hidden.
|
|
49
|
+
self.cursor_type = "column"
|
|
50
|
+
self.show_cursor = False
|
|
51
|
+
self.fixed_columns = 1
|
|
52
|
+
|
|
53
|
+
self._scroll_debounce_delay: float | None = scroll_debounce_delay
|
|
54
|
+
|
|
55
|
+
def watch_current_item(self, item: EvaluationItem | None) -> None:
|
|
56
|
+
"""Rebuild the table when the item changes (Textual reactive pattern)."""
|
|
57
|
+
self.table_ready = False
|
|
58
|
+
|
|
59
|
+
# Reset the cursor to the start (column 1, skipping the fixed 'Field' column)
|
|
60
|
+
self.move_cursor(row=0, column=1, animate=False)
|
|
61
|
+
self.scroll_x = 0
|
|
62
|
+
|
|
63
|
+
if not item:
|
|
64
|
+
self.clear(columns=True)
|
|
65
|
+
return
|
|
66
|
+
|
|
67
|
+
unique_groups = item.get_unique_record_groups()
|
|
68
|
+
|
|
69
|
+
self.clear(columns=True)
|
|
70
|
+
self.add_column("Field", key="field")
|
|
71
|
+
|
|
72
|
+
for i, _ in enumerate(unique_groups):
|
|
73
|
+
# Headers will be populated by _update_headers shortly
|
|
74
|
+
self.add_column("", key=f"col_{i}")
|
|
75
|
+
|
|
76
|
+
for field in item.fields:
|
|
77
|
+
row_values = [Text(field.display_name)]
|
|
78
|
+
for group in unique_groups:
|
|
79
|
+
record_idx = group[0] # Representative record from group
|
|
80
|
+
row_data = item.records.filter(pl.col("leaf") == record_idx).row(
|
|
81
|
+
0, named=True
|
|
82
|
+
)
|
|
83
|
+
# Take first non-empty value from source columns
|
|
84
|
+
value = next(
|
|
85
|
+
(
|
|
86
|
+
str(row_data.get(col, "")).strip()
|
|
87
|
+
for col in field.source_columns
|
|
88
|
+
if row_data.get(col)
|
|
89
|
+
),
|
|
90
|
+
"",
|
|
91
|
+
)
|
|
92
|
+
row_values.append(Text(value))
|
|
93
|
+
self.add_row(*row_values, key=field.display_name)
|
|
94
|
+
|
|
95
|
+
self.table_ready = True
|
|
96
|
+
# Force header update now that columns exist
|
|
97
|
+
self._update_headers()
|
|
98
|
+
|
|
99
|
+
def watch_cursor_coordinate(self, old_value: float, new_value: float) -> None:
|
|
100
|
+
"""Update headers whenever the cursor moves (manual scroll or paging)."""
|
|
101
|
+
self._update_headers()
|
|
102
|
+
|
|
103
|
+
def watch_current_assignments(
|
|
104
|
+
self, old_assignments: dict[int, str], new_assignments: dict[int, str]
|
|
105
|
+
) -> None:
|
|
106
|
+
"""Update column headers and cells when assignments change."""
|
|
107
|
+
# Find which columns changed
|
|
108
|
+
all_group_indices = set(old_assignments.keys()) | set(new_assignments.keys())
|
|
109
|
+
|
|
110
|
+
for group_idx in all_group_indices:
|
|
111
|
+
old_val = old_assignments.get(group_idx)
|
|
112
|
+
new_val = new_assignments.get(group_idx)
|
|
113
|
+
|
|
114
|
+
if old_val != new_val:
|
|
115
|
+
self._paint_column(group_idx, new_val)
|
|
116
|
+
|
|
117
|
+
# Refresh headers (to update status symbols)
|
|
118
|
+
self._update_headers()
|
|
119
|
+
|
|
120
|
+
def on_key(self, event: events.Key) -> None:
|
|
121
|
+
"""Handle keyboard shortcuts for assignment."""
|
|
122
|
+
if event.key in "abcdefghijklmnopqrstuvwxyz":
|
|
123
|
+
self.current_group = event.key
|
|
124
|
+
self.post_message(self.CurrentGroupChanged(event.key))
|
|
125
|
+
event.stop()
|
|
126
|
+
|
|
127
|
+
elif event.key in "123456789":
|
|
128
|
+
if not self.current_item or not self.current_group:
|
|
129
|
+
return
|
|
130
|
+
|
|
131
|
+
key_num = int(event.key)
|
|
132
|
+
|
|
133
|
+
# Group index is relative to the anchor (cursor column). Key '1' is the
|
|
134
|
+
# anchor itself, and the fixed field column (index 0) does not count.
|
|
135
|
+
anchor_col = max(1, self.cursor_column)
|
|
136
|
+
group_idx = (anchor_col - 1) + (key_num - 1)
|
|
137
|
+
|
|
138
|
+
unique_groups = self.current_item.get_unique_record_groups()
|
|
139
|
+
if 0 <= group_idx < len(unique_groups):
|
|
140
|
+
self.post_message(self.AssignmentMade(group_idx, self.current_group))
|
|
141
|
+
|
|
142
|
+
event.stop()
|
|
143
|
+
|
|
144
|
+
def action_page_right(self) -> None:
|
|
145
|
+
"""Move the anchor (cursor) right by 9 columns and force scroll."""
|
|
146
|
+
if not self.current_item:
|
|
147
|
+
return
|
|
148
|
+
|
|
149
|
+
unique_groups = self.current_item.get_unique_record_groups()
|
|
150
|
+
total_cols = len(unique_groups) + self.fixed_columns
|
|
151
|
+
|
|
152
|
+
current_col = self.cursor_column
|
|
153
|
+
|
|
154
|
+
# The last page should be full (9 columns) where possible, so the anchor never
|
|
155
|
+
# goes past total_cols - 9. Example: 20 columns gives max anchor 11, showing
|
|
156
|
+
# columns 11-19.
|
|
157
|
+
max_anchor = max(self.fixed_columns, total_cols - 9)
|
|
158
|
+
|
|
159
|
+
target_col = min(current_col + 9, max_anchor)
|
|
160
|
+
|
|
161
|
+
# Snapping back to max_anchor also recovers from manual scrolling that put the
|
|
162
|
+
# anchor past it.
|
|
163
|
+
if target_col != current_col:
|
|
164
|
+
# Move the cursor without scrolling, then scroll explicitly so the anchor
|
|
165
|
+
# column lands at the left edge rather than wherever Textual defaults to.
|
|
166
|
+
self.move_cursor(column=target_col, animate=False, scroll=False)
|
|
167
|
+
self._scroll_column_to_left(target_col)
|
|
168
|
+
|
|
169
|
+
def action_page_left(self) -> None:
|
|
170
|
+
"""Move the anchor (cursor) left by 9 columns and force scroll."""
|
|
171
|
+
current_col = self.cursor_column
|
|
172
|
+
|
|
173
|
+
# Move left by 9 columns, clamped to the first data column.
|
|
174
|
+
target_col = max(self.fixed_columns, current_col - 9)
|
|
175
|
+
|
|
176
|
+
if target_col != current_col:
|
|
177
|
+
self.move_cursor(column=target_col, animate=False, scroll=False)
|
|
178
|
+
self._scroll_column_to_left(target_col)
|
|
179
|
+
|
|
180
|
+
def _scroll_column_to_left(self, column_index: int) -> None:
|
|
181
|
+
"""Scroll so the target column is at the left edge of the viewport."""
|
|
182
|
+
region = self._get_column_region(column_index)
|
|
183
|
+
target_x = region.x
|
|
184
|
+
|
|
185
|
+
# Fixed columns obscure the left of the viewport, so subtract their width to
|
|
186
|
+
# land the target column just past them, not at absolute x=0.
|
|
187
|
+
fixed_offset = self._get_fixed_offset()
|
|
188
|
+
scroll_target = target_x - fixed_offset.left
|
|
189
|
+
|
|
190
|
+
self.scroll_to(
|
|
191
|
+
x=scroll_target,
|
|
192
|
+
animate=bool(self._scroll_debounce_delay),
|
|
193
|
+
duration=self._scroll_debounce_delay,
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
def _update_headers(self) -> None:
|
|
197
|
+
"""Update column headers based on current cursor position."""
|
|
198
|
+
if not self.current_item or not self.table_ready:
|
|
199
|
+
return
|
|
200
|
+
|
|
201
|
+
unique_groups = self.current_item.get_unique_record_groups()
|
|
202
|
+
|
|
203
|
+
# The anchor is the cursor column, clamped so it never indexes the fixed
|
|
204
|
+
# 'Field' column.
|
|
205
|
+
anchor_col = max(1, self.cursor_column)
|
|
206
|
+
|
|
207
|
+
# Calculate the data index offset (cursor column 1 maps to data index 0)
|
|
208
|
+
data_offset = anchor_col - 1
|
|
209
|
+
|
|
210
|
+
for i, group in enumerate(unique_groups):
|
|
211
|
+
# Visual position 1-9 relative to the anchor
|
|
212
|
+
# i is the data index. Visual position is (i - data_offset) + 1
|
|
213
|
+
visual_pos_idx = i - data_offset + 1
|
|
214
|
+
|
|
215
|
+
visual_pos = None
|
|
216
|
+
if 1 <= visual_pos_idx <= 9:
|
|
217
|
+
visual_pos = visual_pos_idx
|
|
218
|
+
|
|
219
|
+
assignment = self.current_assignments.get(i)
|
|
220
|
+
new_header = self._build_header(visual_pos, group, assignment)
|
|
221
|
+
|
|
222
|
+
# Update DataTable column label (+1 for fixed field column)
|
|
223
|
+
self.ordered_columns[i + 1].label = new_header
|
|
224
|
+
|
|
225
|
+
# DataTable caches rendered labels, so the new ones need this to show.
|
|
226
|
+
self._clear_caches()
|
|
227
|
+
self.refresh()
|
|
228
|
+
|
|
229
|
+
def _paint_column(self, group_idx: int, assignment: str | None) -> None:
|
|
230
|
+
"""Paint or unpaint a column with colour and symbol."""
|
|
231
|
+
if not self.current_item:
|
|
232
|
+
return
|
|
233
|
+
|
|
234
|
+
unique_groups = self.current_item.get_unique_record_groups()
|
|
235
|
+
if group_idx >= len(unique_groups):
|
|
236
|
+
return
|
|
237
|
+
|
|
238
|
+
col_key: ColumnKey = f"col_{group_idx}"
|
|
239
|
+
colour = get_group_style(assignment)[1] if assignment else None
|
|
240
|
+
|
|
241
|
+
for row_key in self.rows:
|
|
242
|
+
cell_value = str(self.get_cell(row_key, col_key))
|
|
243
|
+
text_obj = Text(cell_value)
|
|
244
|
+
if colour:
|
|
245
|
+
text_obj.stylize(colour)
|
|
246
|
+
self.update_cell(row_key, col_key, text_obj)
|
|
247
|
+
|
|
248
|
+
def _build_header(
|
|
249
|
+
self, visual_pos: int | None, group: list[int], assignment: str | None = None
|
|
250
|
+
) -> Text:
|
|
251
|
+
"""Build header with position, count, and optional assignment."""
|
|
252
|
+
dup_count = len(group)
|
|
253
|
+
count_text = f" (×{dup_count})" if dup_count > 1 else ""
|
|
254
|
+
|
|
255
|
+
header = Text()
|
|
256
|
+
|
|
257
|
+
if assignment:
|
|
258
|
+
symbol, colour = get_group_style(assignment)
|
|
259
|
+
pos_text = f" {visual_pos}" if visual_pos is not None else ""
|
|
260
|
+
header.append(f"{symbol}{pos_text}{count_text}")
|
|
261
|
+
header.stylize(colour)
|
|
262
|
+
else:
|
|
263
|
+
if visual_pos is not None:
|
|
264
|
+
header.append(f"{visual_pos}{count_text}", style="dim")
|
|
265
|
+
else:
|
|
266
|
+
header.append(count_text.strip(), style="dim")
|
|
267
|
+
|
|
268
|
+
return header
|
|
269
|
+
|
|
270
|
+
class AssignmentMade(Message):
|
|
271
|
+
"""Posted when a single column assignment is made."""
|
|
272
|
+
|
|
273
|
+
def __init__(self, column_idx: int, group: str) -> None:
|
|
274
|
+
"""Initialise assignment made message."""
|
|
275
|
+
super().__init__()
|
|
276
|
+
self.column_idx = column_idx
|
|
277
|
+
self.group = group
|
|
278
|
+
|
|
279
|
+
class CurrentGroupChanged(Message):
|
|
280
|
+
"""Posted when current group selection changes."""
|
|
281
|
+
|
|
282
|
+
def __init__(self, group: str) -> None:
|
|
283
|
+
"""Initialise current group changed message."""
|
|
284
|
+
super().__init__()
|
|
285
|
+
self.group = group
|
matchlab/lineage.py
ADDED
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
"""Lineage algorithms over a plan tree.
|
|
2
|
+
|
|
3
|
+
Steps hold references to their *inputs* only (`step.upstream`). There is no registry
|
|
4
|
+
and no downstream pointer. "The DAG" is therefore
|
|
5
|
+
whatever is reachable upstream from the node you are holding, and every graph
|
|
6
|
+
operation is a pure function of a root node.
|
|
7
|
+
|
|
8
|
+
Nodes are deduplicated by **object identity**, not by name or config. A node feeding
|
|
9
|
+
two branches is one object and is visited once (structural sharing), while two
|
|
10
|
+
structurally identical but distinct nodes are two plan entries.
|
|
11
|
+
|
|
12
|
+
`draw` also owns the vocabulary for *how a step is doing*, meaning `StepStatus` and
|
|
13
|
+
`StepState`, because the tree is where that vocabulary is read. `matchlab.progress`
|
|
14
|
+
drives it during a collection. Nothing here executes anything.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from collections.abc import Mapping
|
|
18
|
+
from enum import StrEnum
|
|
19
|
+
from typing import TYPE_CHECKING
|
|
20
|
+
|
|
21
|
+
from pydantic import BaseModel, ConfigDict
|
|
22
|
+
|
|
23
|
+
if TYPE_CHECKING:
|
|
24
|
+
# `matchlab.steps` imports this module
|
|
25
|
+
from matchlab.steps import Step
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class StepStatus(StrEnum):
|
|
29
|
+
"""Where a step has got to in a collection.
|
|
30
|
+
|
|
31
|
+
`CACHED` and `DONE` both mean the artifact is available. They differ in whether
|
|
32
|
+
this collection paid for it. That distinction is the whole point of showing
|
|
33
|
+
progress for a content-addressed plan. It is how you see what your edit
|
|
34
|
+
invalidated.
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
PENDING = "pending"
|
|
38
|
+
RUNNING = "running"
|
|
39
|
+
DONE = "done"
|
|
40
|
+
CACHED = "cached"
|
|
41
|
+
FAILED = "failed"
|
|
42
|
+
|
|
43
|
+
@property
|
|
44
|
+
def marker(self) -> str:
|
|
45
|
+
"""A single-width glyph for this status.
|
|
46
|
+
|
|
47
|
+
Deliberately geometric rather than emoji: emoji are double-width in some
|
|
48
|
+
terminals and single in others, which knocks a tree's guide lines out of
|
|
49
|
+
alignment line by line.
|
|
50
|
+
"""
|
|
51
|
+
return _MARKERS[self]
|
|
52
|
+
|
|
53
|
+
@property
|
|
54
|
+
def style(self) -> str:
|
|
55
|
+
"""A Rich style for this status, used when drawing with markup."""
|
|
56
|
+
return _STYLES[self]
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
_MARKERS: dict[StepStatus, str] = {
|
|
60
|
+
StepStatus.PENDING: "○",
|
|
61
|
+
StepStatus.RUNNING: "◐",
|
|
62
|
+
StepStatus.DONE: "●",
|
|
63
|
+
StepStatus.CACHED: "◍",
|
|
64
|
+
StepStatus.FAILED: "✗",
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
_STYLES: dict[StepStatus, str] = {
|
|
68
|
+
StepStatus.PENDING: "dim",
|
|
69
|
+
StepStatus.RUNNING: "bold yellow",
|
|
70
|
+
StepStatus.DONE: "bold green",
|
|
71
|
+
StepStatus.CACHED: "cyan",
|
|
72
|
+
StepStatus.FAILED: "bold red",
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
# Statuses where an elapsed time is worth showing, the ones that spent any.
|
|
77
|
+
_TIMED = (StepStatus.RUNNING, StepStatus.DONE, StepStatus.FAILED)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class StepState(BaseModel):
|
|
81
|
+
"""A step's status, and how long it has been in it."""
|
|
82
|
+
|
|
83
|
+
model_config = ConfigDict(frozen=True)
|
|
84
|
+
|
|
85
|
+
status: StepStatus
|
|
86
|
+
elapsed: float | None = None
|
|
87
|
+
|
|
88
|
+
def annotation(self) -> str:
|
|
89
|
+
"""The trailing detail shown after a step in a drawing, possibly empty.
|
|
90
|
+
|
|
91
|
+
A time is shown only where it was spent. A cached step reads `cached` alone,
|
|
92
|
+
because `0.0s` next to it says nothing.
|
|
93
|
+
"""
|
|
94
|
+
parts: list[str] = []
|
|
95
|
+
if self.status not in (StepStatus.PENDING, StepStatus.DONE):
|
|
96
|
+
parts.append(self.status.value)
|
|
97
|
+
if self.elapsed is not None and self.status in _TIMED:
|
|
98
|
+
parts.append(f"{self.elapsed:.1f}s")
|
|
99
|
+
return " ".join(parts)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def walk(root: "Step") -> list["Step"]:
|
|
103
|
+
"""Return `root` and every transitive input, in topological order.
|
|
104
|
+
|
|
105
|
+
Upstream steps always precede the steps that consume them, so executing the
|
|
106
|
+
returned list in order satisfies every dependency. Implemented as an iterative
|
|
107
|
+
depth-first post-order over the upstream references.
|
|
108
|
+
"""
|
|
109
|
+
ordered: list[Step] = []
|
|
110
|
+
seen: set[int] = set()
|
|
111
|
+
# (step, expanded). `expanded` marks the second visit, when the node's inputs
|
|
112
|
+
# have already been pushed and it is safe to emit.
|
|
113
|
+
stack: list[tuple[Step, bool]] = [(root, False)]
|
|
114
|
+
|
|
115
|
+
while stack:
|
|
116
|
+
step, expanded = stack.pop()
|
|
117
|
+
if expanded:
|
|
118
|
+
ordered.append(step)
|
|
119
|
+
continue
|
|
120
|
+
if id(step) in seen:
|
|
121
|
+
continue
|
|
122
|
+
seen.add(id(step))
|
|
123
|
+
stack.append((step, True))
|
|
124
|
+
for parent in step.upstream:
|
|
125
|
+
if id(parent) not in seen:
|
|
126
|
+
stack.append((parent, False))
|
|
127
|
+
|
|
128
|
+
return ordered
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def number(root: "Step") -> dict[int, int]:
|
|
132
|
+
"""Map each step in `root`'s plan to the position it is known by.
|
|
133
|
+
|
|
134
|
+
A step is referred to by position, in logs, in `draw()`, and in a document.
|
|
135
|
+
The position belongs to the walk rather than to the step: the
|
|
136
|
+
same node numbers differently in `walk(deduped)` and `walk(companies)`, so nothing
|
|
137
|
+
is written back onto the steps. Callers that need one hold this mapping, as
|
|
138
|
+
`draw` does and as `matchlab.progress.Progress` does for the walk `collect` gave it.
|
|
139
|
+
|
|
140
|
+
Returns:
|
|
141
|
+
Step identity to position, in walk order.
|
|
142
|
+
"""
|
|
143
|
+
return {id(step): position for position, step in enumerate(walk(root))}
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def draw(
|
|
147
|
+
root: "Step",
|
|
148
|
+
state: Mapping[int, StepState] | None = None,
|
|
149
|
+
*,
|
|
150
|
+
markup: bool = False,
|
|
151
|
+
) -> str:
|
|
152
|
+
"""`root`'s sub-plan as an indented tree. See `render`, which this joins up."""
|
|
153
|
+
return "\n".join(render(root, state, markup=markup)[0])
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def render(
|
|
157
|
+
root: "Step",
|
|
158
|
+
state: Mapping[int, StepState] | None = None,
|
|
159
|
+
*,
|
|
160
|
+
markup: bool = False,
|
|
161
|
+
) -> tuple[list[str], dict[int, int]]:
|
|
162
|
+
"""Render `root`'s sub-plan as an indented tree, inputs nested beneath consumers.
|
|
163
|
+
|
|
164
|
+
Each node carries its **position**, the index `collect` runs it at, and the index
|
|
165
|
+
it occupies in this plan's document. The index is the cross-reference, where
|
|
166
|
+
`step 7` is the node drawn as `[7]`. Positions come from `walk`, not from the order
|
|
167
|
+
these lines happen to be printed in, since a tree nests consumers above their
|
|
168
|
+
inputs while a walk lists inputs first.
|
|
169
|
+
|
|
170
|
+
Args:
|
|
171
|
+
root: The step to draw, along with everything upstream of it.
|
|
172
|
+
state: Per-step status, keyed by `id(step)` as `number` is. Steps missing from
|
|
173
|
+
the mapping are drawn as `PENDING`. When omitted, each step is drawn from
|
|
174
|
+
its own `is_collected` flag instead, a static view of a plan you hold.
|
|
175
|
+
markup: Emit Rich markup, styling each marker and annotation by status. Off by
|
|
176
|
+
default, so the return value stays printable anywhere.
|
|
177
|
+
|
|
178
|
+
Returns:
|
|
179
|
+
The lines, one per node plus one per repeat reference, and the row each step
|
|
180
|
+
is first drawn on, keyed by `id(step)` as `number` is. The rows are what let a
|
|
181
|
+
caller show part of a tree and still know where it is: `matchlab.progress`
|
|
182
|
+
windows a plan too tall for the terminal around the row that is running.
|
|
183
|
+
|
|
184
|
+
A node feeding several branches is expanded where it is first met and marked `↑`
|
|
185
|
+
everywhere after, rather than having its whole subtree redrawn each time. Positions
|
|
186
|
+
are what make that readable. `↑ [12]` names the node you already have, and without
|
|
187
|
+
it the drawing contradicts the structural sharing it is meant to show. The
|
|
188
|
+
24-step plan in `examples/companies` draws 187 lines expanded, and 39 like this.
|
|
189
|
+
"""
|
|
190
|
+
position = number(root)
|
|
191
|
+
lines: list[str] = []
|
|
192
|
+
expanded: set[int] = set()
|
|
193
|
+
# Where each step is drawn. A repeat reference is a pointer to the expansion, not
|
|
194
|
+
# another copy of it, so the first row is the one worth looking at.
|
|
195
|
+
rows: dict[int, int] = {}
|
|
196
|
+
|
|
197
|
+
def draw_step(step: "Step", prefix: str, connector: str) -> None:
|
|
198
|
+
if state is None:
|
|
199
|
+
marker = "●" if step.is_collected else "○"
|
|
200
|
+
style, annotation = "", ""
|
|
201
|
+
else:
|
|
202
|
+
step_state = state.get(id(step), StepState(status=StepStatus.PENDING))
|
|
203
|
+
marker = step_state.status.marker
|
|
204
|
+
style = step_state.status.style
|
|
205
|
+
annotation = step_state.annotation()
|
|
206
|
+
|
|
207
|
+
repeat = id(step) in expanded
|
|
208
|
+
expanded.add(id(step))
|
|
209
|
+
|
|
210
|
+
label = f"[{position[id(step)]}] {step}"
|
|
211
|
+
if annotation:
|
|
212
|
+
label = f"{label} {annotation}"
|
|
213
|
+
if repeat:
|
|
214
|
+
label = f"{label} ↑"
|
|
215
|
+
|
|
216
|
+
# Escape the whole label, not just the step. `[7]` would itself parse as a
|
|
217
|
+
# style tag.
|
|
218
|
+
row = (
|
|
219
|
+
f"[{style}]{marker}[/] {_escape(label)}" if markup else f"{marker} {label}"
|
|
220
|
+
)
|
|
221
|
+
|
|
222
|
+
rows.setdefault(id(step), len(lines))
|
|
223
|
+
lines.append(f"{prefix}{connector}{row}")
|
|
224
|
+
if repeat: # already drawn in full above, the reference is the whole line
|
|
225
|
+
return
|
|
226
|
+
|
|
227
|
+
child_prefix = prefix + (" " if connector in ("└── ", "") else "│ ")
|
|
228
|
+
parents = step.upstream
|
|
229
|
+
for index, parent in enumerate(parents):
|
|
230
|
+
last = index == len(parents) - 1
|
|
231
|
+
draw_step(parent, child_prefix, "└── " if last else "├── ")
|
|
232
|
+
|
|
233
|
+
draw_step(root, "", "")
|
|
234
|
+
return lines, rows
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def _escape(text: str) -> str:
|
|
238
|
+
"""Escape Rich markup, so a `[7]` isn't read as a style tag."""
|
|
239
|
+
return text.replace("[", r"\[")
|