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,418 @@
1
+ """Textual app that drives `review()`.
2
+
3
+ `EvaluationQueue` holds the sampled clusters still to review, always processing from
4
+ the front. `EntityResolutionApp` composes the UI, handles key bindings, and turns a
5
+ fully painted cluster into a `Judgement` on submit.
6
+ """
7
+
8
+ import logging
9
+ from collections import deque
10
+ from pathlib import Path
11
+ from typing import TYPE_CHECKING
12
+
13
+ from pydantic import BaseModel, ConfigDict, Field
14
+ from textual.app import App, ComposeResult
15
+ from textual.containers import Horizontal, Vertical
16
+ from textual.reactive import reactive
17
+ from textual.timer import Timer
18
+ from textual.widgets import Footer, Label
19
+
20
+ from matchlab.eval.samples import EvaluationItem, create_judgement, get_samples
21
+ from matchlab.eval.tui.modals import HelpModal, NoSamplesModal
22
+ from matchlab.eval.tui.widgets.assignment import AssignmentBar
23
+ from matchlab.eval.tui.widgets.styling import get_group_style
24
+ from matchlab.eval.tui.widgets.table import ComparisonDisplayTable
25
+ from matchlab.steps import default_adapter
26
+
27
+ if TYPE_CHECKING:
28
+ from collections.abc import Sequence
29
+
30
+ from matchlab.adapters import Adapter
31
+ from matchlab.eval.samples import ResolverRef
32
+
33
+ logger = logging.getLogger(__name__)
34
+
35
+
36
+ class CLIEvaluationSession(BaseModel):
37
+ """One queued cluster and the group assignments made for it so far."""
38
+
39
+ model_config = ConfigDict(arbitrary_types_allowed=True)
40
+
41
+ item: EvaluationItem
42
+ assignments: dict[int, str] = Field(default_factory=dict)
43
+
44
+
45
+ class EvaluationQueue:
46
+ """Deque-based queue with current session always at front."""
47
+
48
+ def __init__(self) -> None:
49
+ """Initialise the queue."""
50
+ self.sessions: deque[CLIEvaluationSession] = deque()
51
+
52
+ @property
53
+ def current(self) -> CLIEvaluationSession | None:
54
+ """Get the current session (always at index 0)."""
55
+ return self.sessions[0] if self.sessions else None
56
+
57
+ @property
58
+ def total_count(self) -> int:
59
+ """Total number of sessions in the queue."""
60
+ return len(self.sessions)
61
+
62
+ def skip_current(self) -> None:
63
+ """Move the current session to the back of the queue."""
64
+ if len(self.sessions) > 1:
65
+ self.sessions.rotate(-1)
66
+
67
+ def remove_current(self) -> CLIEvaluationSession | None:
68
+ """Remove and return the current session."""
69
+ return self.sessions.popleft() if self.sessions else None
70
+
71
+ def add_sessions(self, sessions: list[CLIEvaluationSession]) -> int:
72
+ """Add new sessions to the queue, skipping ones already queued.
73
+
74
+ Returns:
75
+ Number of unique sessions added.
76
+ """
77
+ if not sessions:
78
+ return 0
79
+
80
+ existing_ids = {tuple(sorted(session.item.leaves)) for session in self.sessions}
81
+ unique_items = [
82
+ session
83
+ for session in sessions
84
+ if tuple(sorted(session.item.leaves)) not in existing_ids
85
+ ]
86
+
87
+ if unique_items:
88
+ self.sessions.extend(unique_items)
89
+
90
+ return len(unique_items)
91
+
92
+
93
+ class EntityResolutionApp(App):
94
+ """Main Textual application for entity resolution evaluation."""
95
+
96
+ CSS_PATH = Path(__file__).parent / "styles.tcss"
97
+ TITLE = "matchlab evaluate"
98
+ SUB_TITLE = "match labelling tool"
99
+
100
+ # Triggered by action_* methods
101
+ BINDINGS = [
102
+ ("shift+right", "skip", "Skip"),
103
+ ("space", "submit", "Submit"),
104
+ ("escape", "clear", "Clear"),
105
+ ("question_mark,f1", "show_help", "Help"),
106
+ ("ctrl+q,ctrl+c", "quit", "Quit"),
107
+ ]
108
+
109
+ # Reactive variables that trigger UI updates
110
+ status: reactive[tuple[str, str]] = reactive(("○ Ready", "dim"))
111
+ current_item: reactive[EvaluationItem | None] = reactive(None)
112
+ current_assignments: reactive[dict[int, str]] = reactive({}, init=False)
113
+
114
+ sample_limit: int
115
+ resolver: "ResolverRef | Sequence[ResolverRef]"
116
+ adapter: "Adapter | None"
117
+ session_tag: str | None
118
+ seed: int | None
119
+ show_help: bool
120
+
121
+ queue: EvaluationQueue
122
+ timer: Timer | None = None
123
+
124
+ def __init__(
125
+ self,
126
+ resolver: "ResolverRef | Sequence[ResolverRef]",
127
+ num_samples: int = 5,
128
+ adapter: "Adapter | None" = None,
129
+ session_tag: str | None = None,
130
+ seed: int | None = None,
131
+ show_help: bool = False,
132
+ scroll_debounce_delay: float | None = 0.3,
133
+ ) -> None:
134
+ """Initialise the entity resolution app.
135
+
136
+ Args:
137
+ resolver: The resolver whose clusters are reviewed, or the name one was
138
+ published under. The second form reviews a store without the plan
139
+ that built it. Several of either reviews their merged components.
140
+ num_samples: Number of clusters to sample for evaluation.
141
+ adapter: Where judgements are stored. Defaults to the module default.
142
+ session_tag: String to use for tagging judgements.
143
+ seed: Fixes which clusters the session draws, so it can be reproduced.
144
+ show_help: Whether to show help on start.
145
+ scroll_debounce_delay: Delay before updating column headers after scroll.
146
+ Set to None to disable debouncing (useful for tests).
147
+ """
148
+ super().__init__()
149
+
150
+ self.resolver = resolver
151
+ self.sample_limit = num_samples
152
+ self.adapter = adapter
153
+ self.session_tag = session_tag
154
+ self.seed = seed
155
+ self._refills = 0
156
+
157
+ self.show_help = show_help
158
+ self._scroll_debounce_delay = scroll_debounce_delay
159
+ self.queue = EvaluationQueue()
160
+
161
+ # Lifecycle methods
162
+ def compose(self) -> ComposeResult:
163
+ """Compose the main application UI."""
164
+ yield Vertical(
165
+ Horizontal(
166
+ Label("-", id="current-group-label"),
167
+ AssignmentBar(id="assignment-bar"),
168
+ Label(id="status-right"),
169
+ id="status-bar",
170
+ classes="status-bar",
171
+ ),
172
+ ComparisonDisplayTable(
173
+ id="record-table", scroll_debounce_delay=self._scroll_debounce_delay
174
+ ),
175
+ id="main-container",
176
+ )
177
+ yield Footer()
178
+
179
+ async def on_mount(self) -> None:
180
+ """Initialise the application."""
181
+ await self.load_samples()
182
+
183
+ if self.queue.total_count == 0:
184
+ await self._handle_no_samples()
185
+ return
186
+
187
+ self._load_current_item()
188
+
189
+ if self.show_help:
190
+ self.call_after_refresh(self.action_show_help)
191
+
192
+ # Message handlers
193
+ def on_comparison_display_table_assignment_made(
194
+ self, message: ComparisonDisplayTable.AssignmentMade
195
+ ) -> None:
196
+ """Update reactive assignments when table reports an assignment."""
197
+ # Create new dict to trigger reactivity
198
+ new_assignments = {
199
+ **self.current_assignments,
200
+ message.column_idx: message.group,
201
+ }
202
+ self.current_assignments = new_assignments
203
+
204
+ # Save to queue item
205
+ if current := self.queue.current:
206
+ current.assignments = new_assignments
207
+
208
+ # Update assignment bar
209
+ _, colour = get_group_style(message.group)
210
+ assignment_bar = self.query_one("#assignment-bar", AssignmentBar)
211
+ assignment_bar.set_position(message.column_idx, message.group.upper(), colour)
212
+
213
+ def on_comparison_display_table_current_group_changed(
214
+ self, message: ComparisonDisplayTable.CurrentGroupChanged
215
+ ) -> None:
216
+ """Update current group label when table's current group changes."""
217
+ # Update current group label
218
+ if message.group:
219
+ _, colour = get_group_style(message.group)
220
+ label_text = f"[{colour}]{message.group.upper()}[/{colour}]"
221
+ else:
222
+ label_text = "-"
223
+ self.query_one("#current-group-label", Label).update(label_text)
224
+
225
+ # Reactive watchers
226
+ def watch_status(self) -> None:
227
+ """React to status changes."""
228
+ self._update_status_labels()
229
+
230
+ def watch_current_item(self, item: EvaluationItem | None) -> None:
231
+ """Rebuild the assignment bar for the item, and propagate it to the table."""
232
+ table = self.query_one(ComparisonDisplayTable)
233
+ table.current_item = item
234
+
235
+ # Reset assignment bar for new item
236
+ if item:
237
+ unique_record_groups = item.get_unique_record_groups()
238
+ num_columns = len(unique_record_groups)
239
+ assignment_bar = self.query_one("#assignment-bar", AssignmentBar)
240
+ assignment_bar.reset(num_columns)
241
+
242
+ # Load existing assignments into the bar
243
+ if self.current_assignments:
244
+ for col_idx, group in self.current_assignments.items():
245
+ _, colour = get_group_style(group)
246
+ assignment_bar.set_position(col_idx, group.upper(), colour)
247
+
248
+ # Reset current group label
249
+ self.query_one("#current-group-label", Label).update("-")
250
+
251
+ def watch_current_assignments(self, assignments: dict[int, str]) -> None:
252
+ """Propagate assignment changes to the table."""
253
+ table = self.query_one(ComparisonDisplayTable)
254
+ table.current_assignments = assignments
255
+
256
+ # Private methods
257
+ def _load_current_item(self) -> None:
258
+ """Load current queue item into the display."""
259
+ current = self.queue.current
260
+ if current:
261
+ # Set reactive properties (will propagate to table via watchers)
262
+ self.current_assignments = current.assignments.copy()
263
+ self.current_item = current.item
264
+
265
+ def _update_status_labels(self) -> None:
266
+ """Update right status label."""
267
+ self.query_one("#status-right", Label).update(self._build_status_right())
268
+
269
+ def _build_status_right(self) -> str:
270
+ """Build right status text with status indicator."""
271
+ message, colour = self.status
272
+ if message:
273
+ return f"[{colour}]{message}[/]"
274
+ return "[dim]○ Ready[/]"
275
+
276
+ async def _handle_no_samples(self) -> None:
277
+ """Handle empty queue state."""
278
+ self._update_status("◯ No data", "yellow")
279
+ logger.warning("No samples available for this resolver.")
280
+ await self.action_show_no_samples()
281
+
282
+ def _update_status(
283
+ self,
284
+ message: str,
285
+ colour: str = "dim",
286
+ clear_after: float | None = None,
287
+ ) -> None:
288
+ """Update status message with optional auto-clear.
289
+
290
+ Args:
291
+ message: Status message to display.
292
+ colour: Colour for the message.
293
+ clear_after: Seconds after which to auto-clear.
294
+ """
295
+ if self.timer:
296
+ self.timer.stop()
297
+
298
+ self.status = (message, colour)
299
+
300
+ if clear_after:
301
+ self.timer = self.set_timer(
302
+ clear_after, lambda: self._update_status("○ Ready", "dim")
303
+ )
304
+
305
+ # Public methods
306
+ async def load_samples(self) -> None:
307
+ """Fetch more samples from the store until the queue reaches its limit."""
308
+ needed = max(0, self.sample_limit - self.queue.total_count)
309
+
310
+ if needed <= 0:
311
+ return
312
+
313
+ self._update_status("⚡ Fetching", "yellow")
314
+ logger.info(
315
+ "Fetching %s samples to reach limit of %s",
316
+ needed,
317
+ self.sample_limit,
318
+ )
319
+
320
+ # The queue is refilled as it drains, so a fixed seed would fetch the same
321
+ # clusters every time and the leaf-based dedupe would starve it. Moving the
322
+ # seed on per refill keeps a whole session reproducible while each fetch still
323
+ # reaches new clusters. The dedupe drops any overlap between fetches.
324
+ new_samples_dict = None
325
+ try:
326
+ new_samples_dict = get_samples(
327
+ n=needed,
328
+ resolver=self.resolver,
329
+ adapter=self.adapter,
330
+ seed=None if self.seed is None else self.seed + self._refills,
331
+ )
332
+ except Exception as e: # noqa: BLE001
333
+ logger.warning(f"Failed to fetch samples: {type(e).__name__}: {e}")
334
+ finally:
335
+ self._refills += 1
336
+
337
+ if new_samples_dict:
338
+ # Wrap evaluation items in CLI sessions
339
+ sessions = [
340
+ CLIEvaluationSession(item=item) for item in new_samples_dict.values()
341
+ ]
342
+ self.queue.add_sessions(sessions)
343
+
344
+ if self.queue.total_count == 0:
345
+ await self._handle_no_samples()
346
+ else:
347
+ self._update_status("✓ Ready", "green")
348
+
349
+ # Action methods (public interface)
350
+ async def action_skip(self) -> None:
351
+ """Skip current entity (moves to back of queue)."""
352
+ if self.queue.total_count > 1:
353
+ self.queue.skip_current()
354
+ self._load_current_item()
355
+ else:
356
+ await self.action_submit()
357
+ if self.queue.total_count == 0:
358
+ self.exit()
359
+
360
+ async def action_submit(self) -> None:
361
+ """Submit current entity if fully painted."""
362
+ current = self.queue.current
363
+
364
+ if not current:
365
+ return
366
+
367
+ # Check if all columns are assigned
368
+ unique_record_groups = current.item.get_unique_record_groups()
369
+ is_painted = len(current.assignments) == len(unique_record_groups)
370
+ if not is_painted:
371
+ self._update_status("⚠ Incomplete", "yellow", clear_after=3.0)
372
+ return
373
+
374
+ try:
375
+ judgement = create_judgement(
376
+ item=current.item,
377
+ assignments=current.assignments,
378
+ tag=self.session_tag,
379
+ )
380
+ adapter = self.adapter or default_adapter()
381
+ adapter.store_judgement(judgement)
382
+ except Exception as exc:
383
+ self._update_status("⚠ Send failed", "red", clear_after=4.0)
384
+ logger.exception(f"Failed to submit: {exc}")
385
+ return
386
+
387
+ self.queue.remove_current()
388
+ await self.load_samples()
389
+ self._load_current_item()
390
+ self._update_status("✓ Sent", "green", clear_after=2.0)
391
+
392
+ async def action_clear(self) -> None:
393
+ """Clear current entity's group assignments."""
394
+ if self.queue.current:
395
+ # Clear reactive assignments (will propagate to table)
396
+ self.current_assignments = {}
397
+
398
+ # Update queue item
399
+ self.queue.current.assignments = {}
400
+
401
+ # Reset assignment bar and current group label
402
+ if self.current_item:
403
+ unique_record_groups = self.current_item.get_unique_record_groups()
404
+ assignment_bar = self.query_one("#assignment-bar", AssignmentBar)
405
+ assignment_bar.reset(len(unique_record_groups))
406
+ self.query_one("#current-group-label", Label).update("-")
407
+
408
+ async def action_show_help(self) -> None:
409
+ """Show the help modal."""
410
+ self.push_screen(HelpModal())
411
+
412
+ async def action_show_no_samples(self) -> None:
413
+ """Show the no samples modal."""
414
+ self.push_screen(NoSamplesModal())
415
+
416
+ async def action_quit(self) -> None:
417
+ """Quit the application."""
418
+ self.exit()
@@ -0,0 +1,84 @@
1
+ """Modal screens for entity resolution evaluation."""
2
+
3
+ from textual import events, on
4
+ from textual.app import ComposeResult
5
+ from textual.containers import Container
6
+ from textual.screen import ModalScreen
7
+ from textual.widgets import Button, Static
8
+
9
+ HELP_TEXT = """
10
+ A model thinks this data is one entity. Is it?
11
+
12
+ [b]Paint the columns to label this as one or more entities.[/b]
13
+
14
+ • Each column is data from a single source.
15
+ • Each row is a field from that source.
16
+
17
+ If you think the model got it right, paint all columns the same.
18
+
19
+ If you think column 1 and 2 belong to one entity, and 3-5 another,
20
+ paint them that way.
21
+
22
+ When you're done, submit, and do another!
23
+
24
+ • Press letter (a-z): Select that group (26 groups available)
25
+ • Press number (1-9, 0): Assign column to selected group
26
+ • Space: Submit completed review
27
+ • Esc: Clear group selection
28
+ • Shift+Right: Skip to next entity
29
+ • Left/Right: Switch between tabs
30
+ • Up/Down: Page through rows
31
+ """.strip()
32
+
33
+ NO_SAMPLES_TEXT = """
34
+ No samples are available for this resolver.
35
+
36
+ Possible reasons:
37
+ • All clusters have been recently judged by you
38
+ • The resolver has no score data
39
+ • No clusters exist for this resolver
40
+
41
+ Press Ctrl+Q to quit.
42
+ """.strip()
43
+
44
+
45
+ class HelpModal(ModalScreen):
46
+ """Help screen showing commands and shortcuts."""
47
+
48
+ def compose(self) -> ComposeResult:
49
+ """Compose the help modal UI."""
50
+ with Container(id="help-dialog"):
51
+ yield Static("Paint the columns!", id="help-title")
52
+ yield Static(HELP_TEXT, id="help-content")
53
+ yield Button("Close (Esc)", id="close-help")
54
+
55
+ @on(Button.Pressed, "#close-help")
56
+ def close_help(self) -> None:
57
+ """Close the help modal."""
58
+ self.dismiss()
59
+
60
+ def on_key(self, event: events.Key) -> None:
61
+ """Handle key events for closing the help modal."""
62
+ if event.key in ("escape", "question_mark", "f1"):
63
+ self.dismiss()
64
+
65
+
66
+ class NoSamplesModal(ModalScreen):
67
+ """Modal screen showing no samples are available."""
68
+
69
+ def compose(self) -> ComposeResult:
70
+ """Compose the no samples modal UI."""
71
+ with Container(id="no-samples-dialog"):
72
+ yield Static("No samples available", id="no-samples-title")
73
+ yield Static(NO_SAMPLES_TEXT, id="no-samples-content")
74
+ yield Button("Quit (Ctrl+Q)", id="quit-no-samples")
75
+
76
+ @on(Button.Pressed, "#quit-no-samples")
77
+ def quit_app(self) -> None:
78
+ """Quit the application."""
79
+ self.app.exit()
80
+
81
+ def on_key(self, event: events.Key) -> None:
82
+ """Handle key events for the no samples modal."""
83
+ if event.key in ("escape", "ctrl+q", "ctrl+c"):
84
+ self.app.exit()
@@ -0,0 +1,184 @@
1
+ /* Entity Resolution UI Styles */
2
+
3
+ Screen {
4
+ align: center top;
5
+ }
6
+
7
+ /* Main container - expanded to fill space */
8
+ #main-container {
9
+ width: 100%;
10
+ height: 100%;
11
+ }
12
+
13
+ /* Modal dialogs */
14
+ #help-dialog {
15
+ width: 80;
16
+ height: 30;
17
+ background: $surface;
18
+ border: thick $primary;
19
+ }
20
+
21
+ #help-title {
22
+ text-align: center;
23
+ text-style: bold;
24
+ color: $accent;
25
+ margin: 1;
26
+ }
27
+
28
+ #help-content {
29
+ margin: 1 2;
30
+ height: 20;
31
+ }
32
+
33
+ #close-help {
34
+ margin: 1 2;
35
+ width: 20;
36
+ }
37
+
38
+ #no-samples-dialog {
39
+ width: 65;
40
+ height: 18;
41
+ background: $surface;
42
+ border: thick $primary;
43
+ }
44
+
45
+ #no-samples-title {
46
+ text-align: center;
47
+ text-style: bold;
48
+ color: $accent;
49
+ margin: 1;
50
+ }
51
+
52
+ #no-samples-content {
53
+ margin: 1 2;
54
+ }
55
+
56
+ #quit-no-samples {
57
+ margin: 1 2;
58
+ width: 20;
59
+ }
60
+
61
+ /* Status bar */
62
+ #status-bar {
63
+ height: 3;
64
+ background: #1B2E46;
65
+ padding: 0;
66
+ width: 100%;
67
+ }
68
+
69
+ #current-group-label {
70
+ width: auto;
71
+ height: auto;
72
+ padding: 0 1;
73
+ border: solid #E4A242;
74
+ text-align: center;
75
+ }
76
+
77
+ #assignment-bar {
78
+ width: 1fr;
79
+ text-align: left;
80
+ padding: 1 1;
81
+ }
82
+
83
+ #status-right {
84
+ width: auto;
85
+ text-align: right;
86
+ padding: 1 1;
87
+ }
88
+
89
+ /* Table */
90
+ #record-table {
91
+ height: 1fr;
92
+ width: 100%;
93
+ background: $surface;
94
+ }
95
+
96
+ /* DataTable styling */
97
+ ComparisonDisplayTable {
98
+ height: 1fr;
99
+ width: 100%;
100
+
101
+ /* Scrollbar styling */
102
+ scrollbar-color: #342c20;
103
+ scrollbar-color-hover: #E4A242;
104
+ scrollbar-color-active: #E4A242;
105
+ scrollbar-background: #0d1318;
106
+ scrollbar-background-hover: #10161d;
107
+ scrollbar-background-active: #10161d;
108
+ scrollbar-corner-color: #0d1318;
109
+ }
110
+
111
+ /* Disable default focus tint on table background */
112
+ ComparisonDisplayTable:focus {
113
+ background-tint: transparent 0%;
114
+ }
115
+
116
+ /* Header styling - matches fixed column */
117
+ ComparisonDisplayTable > .datatable--header {
118
+ text-style: bold;
119
+ background: #18283C;
120
+ }
121
+
122
+ /* Prevent header color changes on focus/hover/cursor */
123
+ ComparisonDisplayTable:focus > .datatable--header,
124
+ ComparisonDisplayTable > .datatable--header-cursor,
125
+ ComparisonDisplayTable > .datatable--header-hover {
126
+ background: #18283C;
127
+ background-tint: transparent 0%;
128
+ }
129
+
130
+ /* Fixed column (row header) styling - matches header */
131
+ ComparisonDisplayTable > .datatable--fixed,
132
+ ComparisonDisplayTable:focus > .datatable--fixed {
133
+ text-style: bold;
134
+ background: #18283C;
135
+ }
136
+
137
+ /* Zebra striping with subtle grey-blue tones */
138
+ ComparisonDisplayTable > .datatable--odd-row {
139
+ background: #0d1318;
140
+ }
141
+
142
+ ComparisonDisplayTable > .datatable--even-row {
143
+ background: #10161d;
144
+ }
145
+
146
+ /* Footer */
147
+ Footer {
148
+ background: #1B2E46;
149
+ color: $text;
150
+ }
151
+
152
+ Footer > .footer--highlight {
153
+ background: #1B2E46;
154
+ }
155
+
156
+ Footer > .footer--highlight-key {
157
+ background: #E4A242;
158
+ color: #1B2E46;
159
+ }
160
+
161
+ Footer > .footer--key {
162
+ background: #E4A242;
163
+ color: #1B2E46;
164
+ }
165
+
166
+ Footer > .footer--description {
167
+ color: $text;
168
+ }
169
+
170
+ /* Other views */
171
+ #pr-curve-display {
172
+ height: auto;
173
+ width: 100%;
174
+ overflow: auto;
175
+ }
176
+
177
+ .command-area {
178
+ height: 3;
179
+ background: #1B2E46;
180
+ padding: 1;
181
+ }
182
+
183
+ /* Dynamic group styles will be generated at runtime */
184
+ /* No need for hardcoded group-a through group-g classes */
@@ -0,0 +1 @@
1
+ """UI widgets for entity resolution evaluation tool."""