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.
Files changed (72) hide show
  1. matchlab/__init__.py +55 -0
  2. matchlab/adapters/__init__.py +24 -0
  3. matchlab/adapters/base.py +446 -0
  4. matchlab/adapters/duckdb.py +659 -0
  5. matchlab/cli.py +177 -0
  6. matchlab/core/__init__.py +1 -0
  7. matchlab/core/dataframes.py +254 -0
  8. matchlab/core/dsu.py +62 -0
  9. matchlab/core/exceptions.py +40 -0
  10. matchlab/core/hash.py +141 -0
  11. matchlab/core/kinds.py +12 -0
  12. matchlab/core/logging.py +190 -0
  13. matchlab/core/resolver_output.py +173 -0
  14. matchlab/core/schemas.py +82 -0
  15. matchlab/core/sql.py +12 -0
  16. matchlab/document.py +329 -0
  17. matchlab/eval/__init__.py +11 -0
  18. matchlab/eval/judgements.py +41 -0
  19. matchlab/eval/metrics.py +244 -0
  20. matchlab/eval/samples.py +415 -0
  21. matchlab/eval/tui/__init__.py +66 -0
  22. matchlab/eval/tui/app.py +418 -0
  23. matchlab/eval/tui/modals.py +84 -0
  24. matchlab/eval/tui/styles.tcss +184 -0
  25. matchlab/eval/tui/widgets/__init__.py +1 -0
  26. matchlab/eval/tui/widgets/assignment.py +72 -0
  27. matchlab/eval/tui/widgets/styling.py +75 -0
  28. matchlab/eval/tui/widgets/table.py +285 -0
  29. matchlab/lineage.py +239 -0
  30. matchlab/locations.py +347 -0
  31. matchlab/models/__init__.py +10 -0
  32. matchlab/models/comparison.py +67 -0
  33. matchlab/models/dedupers/__init__.py +5 -0
  34. matchlab/models/dedupers/base.py +38 -0
  35. matchlab/models/dedupers/naive.py +77 -0
  36. matchlab/models/linkers/__init__.py +9 -0
  37. matchlab/models/linkers/base.py +41 -0
  38. matchlab/models/linkers/deterministic.py +229 -0
  39. matchlab/models/linkers/splinklinker.py +262 -0
  40. matchlab/models/linkers/weighteddeterministic.py +143 -0
  41. matchlab/models/models.py +198 -0
  42. matchlab/progress.py +455 -0
  43. matchlab/recordstep.py +181 -0
  44. matchlab/resolvers/__init__.py +10 -0
  45. matchlab/resolvers/base.py +63 -0
  46. matchlab/resolvers/components.py +66 -0
  47. matchlab/resolvers/resolvers.py +409 -0
  48. matchlab/sources.py +291 -0
  49. matchlab/specs.py +129 -0
  50. matchlab/steps.py +284 -0
  51. matchlab/testkit/__init__.py +104 -0
  52. matchlab/testkit/_generate.py +240 -0
  53. matchlab/testkit/compare.py +178 -0
  54. matchlab/testkit/entities.py +290 -0
  55. matchlab/testkit/features.py +146 -0
  56. matchlab/testkit/linked.py +527 -0
  57. matchlab/testkit/matchers.py +233 -0
  58. matchlab/testkit/models.py +165 -0
  59. matchlab/testkit/sources.py +327 -0
  60. matchlab/transformers/__init__.py +24 -0
  61. matchlab/transformers/base.py +41 -0
  62. matchlab/transformers/clean.py +48 -0
  63. matchlab/transformers/explode.py +67 -0
  64. matchlab/transformers/group.py +56 -0
  65. matchlab/transformers/select.py +31 -0
  66. matchlab/transformers/transform.py +101 -0
  67. matchlab-0.1.0.dev1.dist-info/METADATA +88 -0
  68. matchlab-0.1.0.dev1.dist-info/RECORD +72 -0
  69. matchlab-0.1.0.dev1.dist-info/WHEEL +5 -0
  70. matchlab-0.1.0.dev1.dist-info/entry_points.txt +2 -0
  71. matchlab-0.1.0.dev1.dist-info/licenses/LICENSE +21 -0
  72. 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"\[")