extract-tracker 0.2.2__tar.gz → 0.2.3__tar.gz

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 (58) hide show
  1. {extract_tracker-0.2.2 → extract_tracker-0.2.3}/PKG-INFO +1 -1
  2. {extract_tracker-0.2.2 → extract_tracker-0.2.3}/manual/mcp.md +24 -0
  3. {extract_tracker-0.2.2 → extract_tracker-0.2.3}/pyproject.toml +1 -1
  4. {extract_tracker-0.2.2 → extract_tracker-0.2.3}/python/src/extract/__main__.py +38 -13
  5. extract_tracker-0.2.3/python/src/extract/cli_query.py +223 -0
  6. {extract_tracker-0.2.2 → extract_tracker-0.2.3}/python/src/extract/mcp.py +165 -87
  7. {extract_tracker-0.2.2 → extract_tracker-0.2.3}/python/src/extract_tracker.egg-info/PKG-INFO +1 -1
  8. {extract_tracker-0.2.2 → extract_tracker-0.2.3}/python/src/extract_tracker.egg-info/SOURCES.txt +1 -0
  9. {extract_tracker-0.2.2 → extract_tracker-0.2.3}/rust/Cargo.lock +1 -1
  10. {extract_tracker-0.2.2 → extract_tracker-0.2.3}/rust/Cargo.toml +1 -1
  11. {extract_tracker-0.2.2 → extract_tracker-0.2.3}/MANIFEST.in +0 -0
  12. {extract_tracker-0.2.2 → extract_tracker-0.2.3}/README.md +0 -0
  13. {extract_tracker-0.2.2 → extract_tracker-0.2.3}/manual/config.md +0 -0
  14. {extract_tracker-0.2.2 → extract_tracker-0.2.3}/manual/packaging.md +0 -0
  15. {extract_tracker-0.2.2 → extract_tracker-0.2.3}/manual/sync.md +0 -0
  16. {extract_tracker-0.2.2 → extract_tracker-0.2.3}/manual/tui.md +0 -0
  17. {extract_tracker-0.2.2 → extract_tracker-0.2.3}/manual/usage.md +0 -0
  18. {extract_tracker-0.2.2 → extract_tracker-0.2.3}/python/src/extract/__init__.py +0 -0
  19. {extract_tracker-0.2.2 → extract_tracker-0.2.3}/python/src/extract/experiment.py +0 -0
  20. {extract_tracker-0.2.2 → extract_tracker-0.2.3}/python/src/extract/init.py +0 -0
  21. {extract_tracker-0.2.2 → extract_tracker-0.2.3}/python/src/extract/metrics.py +0 -0
  22. {extract_tracker-0.2.2 → extract_tracker-0.2.3}/python/src/extract/release_versioning.py +0 -0
  23. {extract_tracker-0.2.2 → extract_tracker-0.2.3}/python/src/extract/run.py +0 -0
  24. {extract_tracker-0.2.2 → extract_tracker-0.2.3}/python/src/extract/store.py +0 -0
  25. {extract_tracker-0.2.2 → extract_tracker-0.2.3}/python/src/extract/sync.py +0 -0
  26. {extract_tracker-0.2.2 → extract_tracker-0.2.3}/python/src/extract_tracker.egg-info/dependency_links.txt +0 -0
  27. {extract_tracker-0.2.2 → extract_tracker-0.2.3}/python/src/extract_tracker.egg-info/entry_points.txt +0 -0
  28. {extract_tracker-0.2.2 → extract_tracker-0.2.3}/python/src/extract_tracker.egg-info/requires.txt +0 -0
  29. {extract_tracker-0.2.2 → extract_tracker-0.2.3}/python/src/extract_tracker.egg-info/top_level.txt +0 -0
  30. {extract_tracker-0.2.2 → extract_tracker-0.2.3}/rust/src/app.rs +0 -0
  31. {extract_tracker-0.2.2 → extract_tracker-0.2.3}/rust/src/artifact.rs +0 -0
  32. {extract_tracker-0.2.2 → extract_tracker-0.2.3}/rust/src/config.rs +0 -0
  33. {extract_tracker-0.2.2 → extract_tracker-0.2.3}/rust/src/db.rs +0 -0
  34. {extract_tracker-0.2.2 → extract_tracker-0.2.3}/rust/src/event.rs +0 -0
  35. {extract_tracker-0.2.2 → extract_tracker-0.2.3}/rust/src/keys.rs +0 -0
  36. {extract_tracker-0.2.2 → extract_tracker-0.2.3}/rust/src/main.rs +0 -0
  37. {extract_tracker-0.2.2 → extract_tracker-0.2.3}/rust/src/model.rs +0 -0
  38. {extract_tracker-0.2.2 → extract_tracker-0.2.3}/rust/src/ui/chart.rs +0 -0
  39. {extract_tracker-0.2.2 → extract_tracker-0.2.3}/rust/src/ui/compare.rs +0 -0
  40. {extract_tracker-0.2.2 → extract_tracker-0.2.3}/rust/src/ui/dashboard.rs +0 -0
  41. {extract_tracker-0.2.2 → extract_tracker-0.2.3}/rust/src/ui/detail.rs +0 -0
  42. {extract_tracker-0.2.2 → extract_tracker-0.2.3}/rust/src/ui/diff.rs +0 -0
  43. {extract_tracker-0.2.2 → extract_tracker-0.2.3}/rust/src/ui/heatmap.rs +0 -0
  44. {extract_tracker-0.2.2 → extract_tracker-0.2.3}/rust/src/ui/help.rs +0 -0
  45. {extract_tracker-0.2.2 → extract_tracker-0.2.3}/rust/src/ui/layout.rs +0 -0
  46. {extract_tracker-0.2.2 → extract_tracker-0.2.3}/rust/src/ui/lineage.rs +0 -0
  47. {extract_tracker-0.2.2 → extract_tracker-0.2.3}/rust/src/ui/mod.rs +0 -0
  48. {extract_tracker-0.2.2 → extract_tracker-0.2.3}/rust/src/ui/popup.rs +0 -0
  49. {extract_tracker-0.2.2 → extract_tracker-0.2.3}/rust/src/ui/registry.rs +0 -0
  50. {extract_tracker-0.2.2 → extract_tracker-0.2.3}/rust/src/ui/search.rs +0 -0
  51. {extract_tracker-0.2.2 → extract_tracker-0.2.3}/rust/src/ui/selection.rs +0 -0
  52. {extract_tracker-0.2.2 → extract_tracker-0.2.3}/rust/src/ui/statusbar.rs +0 -0
  53. {extract_tracker-0.2.2 → extract_tracker-0.2.3}/rust/src/ui/summary.rs +0 -0
  54. {extract_tracker-0.2.2 → extract_tracker-0.2.3}/rust/src/ui/theme.rs +0 -0
  55. {extract_tracker-0.2.2 → extract_tracker-0.2.3}/rust/src/ui/todo.rs +0 -0
  56. {extract_tracker-0.2.2 → extract_tracker-0.2.3}/rust/src/ui/tree.rs +0 -0
  57. {extract_tracker-0.2.2 → extract_tracker-0.2.3}/setup.cfg +0 -0
  58. {extract_tracker-0.2.2 → extract_tracker-0.2.3}/setup.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: extract-tracker
3
- Version: 0.2.2
3
+ Version: 0.2.3
4
4
  Summary: Local-first experiment tracking for deep learning
5
5
  Author: Phil Oh
6
6
  Keywords: experiment-tracking,machine-learning,deep-learning,sqlite,tui,mcp
@@ -47,3 +47,27 @@ All tools are read-only.
47
47
  | `list_models` | Registered models with metadata |
48
48
 
49
49
  Full schemas, response shapes, and error catalog live in [`DOC.md`](../DOC.md#mcp-server).
50
+
51
+ ## Agent CLI surface
52
+
53
+ The same read-only operations are also exposed as JSON CLI commands for agents or hosts that cannot attach the MCP server:
54
+
55
+ ```bash
56
+ extract experiments list --store .extract --format json
57
+ extract runs list --limit 20 --store .extract --format json
58
+ extract runs get RUN_ID --store .extract --format json
59
+ extract runs compare RUN_A RUN_B --store .extract --format json
60
+ extract search --query resnet --tag production-candidate --store .extract --format json
61
+ extract todos list --scope-type run --scope-id RUN_ID --store .extract --format json
62
+ extract lineage get run RUN_ID --direction both --store .extract --format json
63
+ extract models list --store .extract --format json
64
+ ```
65
+
66
+ An installable Pi package in `pi/extract-pi-tools/` wraps these commands as native Pi tools such as `extract_list_runs`, `extract_get_run`, and `extract_compare_runs`:
67
+
68
+ ```bash
69
+ pi install ./pi/extract-pi-tools # user-scoped
70
+ pi install -l ./pi/extract-pi-tools # project-scoped
71
+ ```
72
+
73
+ Reload or restart Pi after installation. These native tools are context-safe summaries, not raw dumps: list tools cap rows, `extract_get_run` omits full config unless specific `configKeys` are requested, and large raw JSON is written to a temp file path. For full configs, curves, or bulk aggregation, rerun the returned `meta.command` inside `ctx_execute` and print only the derived answer.
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "extract-tracker"
7
- version = "0.2.2"
7
+ version = "0.2.3"
8
8
  description = "Local-first experiment tracking for deep learning"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -29,7 +29,9 @@ def _find_tui_binary() -> str | None:
29
29
  return None
30
30
 
31
31
 
32
- def _print_merge_stats(verb: str, src: object, dst: object, stats: dict[str, int]) -> None:
32
+ def _print_merge_stats(
33
+ verb: str, src: object, dst: object, stats: dict[str, int]
34
+ ) -> None:
33
35
  print(f"{verb} {src} → {dst}")
34
36
  exps = stats.get("experiments", 0)
35
37
  runs = stats.get("runs", 0)
@@ -45,29 +47,39 @@ def _print_merge_stats(verb: str, src: object, dst: object, stats: dict[str, int
45
47
 
46
48
 
47
49
  def main(argv: list[str] | None = None) -> None:
48
- parser = argparse.ArgumentParser(prog="extract", description="Extract experiment tracker")
50
+ parser = argparse.ArgumentParser(
51
+ prog="extract", description="Extract experiment tracker"
52
+ )
49
53
  sub = parser.add_subparsers(dest="command")
50
54
 
55
+ from extract.cli_query import add_query_parsers, run_query_command
56
+
57
+ add_query_parsers(sub)
58
+
51
59
  # --- tui ---
52
60
  tui_parser = sub.add_parser("tui", help="Launch the TUI explorer")
53
- tui_parser.add_argument("--store", default=".extract", help="Path to .extract/ directory")
61
+ tui_parser.add_argument(
62
+ "--store", default=".extract", help="Path to .extract/ directory"
63
+ )
54
64
 
55
65
  # --- init ---
56
66
  init_parser = sub.add_parser(
57
67
  "init", help="Bootstrap a .extract/ store with a hierarchy"
58
68
  )
59
69
  init_parser.add_argument(
60
- "path", nargs="?", default=".extract",
61
- help="Path to create the store at (default: .extract)"
70
+ "path",
71
+ nargs="?",
72
+ default=".extract",
73
+ help="Path to create the store at (default: .extract)",
62
74
  )
63
75
  init_parser.add_argument(
64
- "--hierarchy", default=None,
76
+ "--hierarchy",
77
+ default=None,
65
78
  help="Skip the interactive picker; use this hierarchy "
66
- "(e.g. 'benchmark > model > variant')"
79
+ "(e.g. 'benchmark > model > variant')",
67
80
  )
68
81
  init_parser.add_argument(
69
- "--no-gitignore", action="store_true",
70
- help="Do not add .extract/ to .gitignore"
82
+ "--no-gitignore", action="store_true", help="Do not add .extract/ to .gitignore"
71
83
  )
72
84
 
73
85
  # --- sync ---
@@ -84,14 +96,24 @@ def main(argv: list[str] | None = None) -> None:
84
96
 
85
97
  export_p = sync_sub.add_parser("export", help="Archive .extract/ to tar.gz")
86
98
  export_p.add_argument("output", help="Output archive path, e.g. experiments.tar.gz")
87
- export_p.add_argument("--root", default=".extract", help="Local .extract/ directory")
99
+ export_p.add_argument(
100
+ "--root", default=".extract", help="Local .extract/ directory"
101
+ )
88
102
 
89
- import_p = sync_sub.add_parser("import", help="Import tar.gz archive into .extract/")
103
+ import_p = sync_sub.add_parser(
104
+ "import", help="Import tar.gz archive into .extract/"
105
+ )
90
106
  import_p.add_argument("archive", help="Archive path to import")
91
- import_p.add_argument("--root", default=".extract", help="Target .extract/ directory")
107
+ import_p.add_argument(
108
+ "--root", default=".extract", help="Target .extract/ directory"
109
+ )
92
110
 
93
111
  args = parser.parse_args(argv)
94
112
 
113
+ query_exit = run_query_command(args)
114
+ if query_exit is not None:
115
+ sys.exit(query_exit)
116
+
95
117
  if args.command == "tui":
96
118
  binary = _find_tui_binary()
97
119
  if binary is None:
@@ -105,7 +127,10 @@ def main(argv: list[str] | None = None) -> None:
105
127
  os.execvp(binary, [binary, "--store", args.store])
106
128
 
107
129
  elif args.command == "init":
108
- from extract import init # lazy import — pulls in rich/questionary only when needed
130
+ from extract import (
131
+ init,
132
+ ) # lazy import — pulls in rich/questionary only when needed
133
+
109
134
  sys.exit(init.run(args))
110
135
 
111
136
  elif args.command == "sync":
@@ -0,0 +1,223 @@
1
+ """JSON CLI adapter for Extract read-only query tools.
2
+
3
+ This module reuses the existing MCP tool implementations so the command-line
4
+ surface and MCP surface share one result contract while the query core is
5
+ factored out in a later cleanup.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import argparse
11
+ import json
12
+ import sys
13
+ from pathlib import Path
14
+ from typing import Any
15
+
16
+ from extract.store import Store
17
+
18
+
19
+ def _add_common(parser: argparse.ArgumentParser) -> None:
20
+ parser.add_argument(
21
+ "--store", default=".extract", help="Path to .extract/ directory"
22
+ )
23
+ parser.add_argument(
24
+ "--format",
25
+ choices=("json",),
26
+ default="json",
27
+ help="Output format (default: json)",
28
+ )
29
+
30
+
31
+ def add_query_parsers(sub: argparse._SubParsersAction[argparse.ArgumentParser]) -> None:
32
+ """Register read-only query subcommands on the top-level CLI parser."""
33
+ experiments = sub.add_parser("experiments", help="Read experiments from a store")
34
+ exp_sub = experiments.add_subparsers(dest="query_action")
35
+ exp_list = exp_sub.add_parser("list", help="List experiments")
36
+ _add_common(exp_list)
37
+ exp_list.add_argument("--prefix", default="", help="Experiment path prefix")
38
+ exp_list.add_argument("--limit", type=int, default=50, help="Max rows (max 500)")
39
+ exp_list.add_argument("--include-archived", action="store_true")
40
+ exp_list.set_defaults(_extract_query="list_experiments")
41
+
42
+ runs = sub.add_parser("runs", help="Read runs from a store")
43
+ runs_sub = runs.add_subparsers(dest="query_action")
44
+ runs_list = runs_sub.add_parser("list", help="List runs")
45
+ _add_common(runs_list)
46
+ runs_list.add_argument(
47
+ "--experiment-id", default=None, help="Scope to one experiment id"
48
+ )
49
+ runs_list.add_argument("--limit", type=int, default=50, help="Max rows (max 500)")
50
+ runs_list.add_argument("--include-archived", action="store_true")
51
+ runs_list.set_defaults(_extract_query="list_runs")
52
+
53
+ runs_get = runs_sub.add_parser("get", help="Get full run detail")
54
+ _add_common(runs_get)
55
+ runs_get.add_argument("run_id", help="Run ULID")
56
+ runs_get.set_defaults(_extract_query="get_run")
57
+
58
+ runs_compare = runs_sub.add_parser("compare", help="Compare 2-10 runs")
59
+ _add_common(runs_compare)
60
+ runs_compare.add_argument("run_ids", nargs="+", help="Run ULIDs")
61
+ runs_compare.add_argument("--include-curves", action="store_true")
62
+ runs_compare.set_defaults(_extract_query="compare_runs")
63
+
64
+ search = sub.add_parser("search", help="Search runs")
65
+ _add_common(search)
66
+ search.add_argument("--query", default="", help="Case-insensitive text search")
67
+ search.add_argument("--tag", default=None, help="Require tag")
68
+ search.add_argument("--status", default=None, help="Require status")
69
+ search.add_argument(
70
+ "--experiment-prefix", default=None, help="Experiment path prefix"
71
+ )
72
+ search.add_argument(
73
+ "--started-after", default=None, help="ISO timestamp lower bound"
74
+ )
75
+ search.add_argument(
76
+ "--started-before", default=None, help="ISO timestamp upper bound"
77
+ )
78
+ search.add_argument("--limit", type=int, default=50, help="Max rows (max 500)")
79
+ search.add_argument("--include-archived", action="store_true")
80
+ search.set_defaults(_extract_query="search")
81
+
82
+ todos = sub.add_parser("todos", help="Read TODOs from a store")
83
+ todos_sub = todos.add_subparsers(dest="query_action")
84
+ todos_list = todos_sub.add_parser("list", help="List TODOs")
85
+ _add_common(todos_list)
86
+ todos_list.add_argument(
87
+ "--scope-type",
88
+ default="global",
89
+ choices=("global", "experiment", "run"),
90
+ help="TODO scope",
91
+ )
92
+ todos_list.add_argument(
93
+ "--scope-id", default=None, help="Required for experiment/run scope"
94
+ )
95
+ todos_list.add_argument("--include-done", action="store_true")
96
+ todos_list.add_argument("--limit", type=int, default=50, help="Max rows (max 500)")
97
+ todos_list.set_defaults(_extract_query="list_todos")
98
+
99
+ lineage = sub.add_parser("lineage", help="Read lineage graph from a store")
100
+ lineage_sub = lineage.add_subparsers(dest="query_action")
101
+ lineage_get = lineage_sub.add_parser("get", help="Walk lineage DAG")
102
+ _add_common(lineage_get)
103
+ lineage_get.add_argument("node_type", choices=("experiment", "run", "model"))
104
+ lineage_get.add_argument("node_id")
105
+ lineage_get.add_argument(
106
+ "--direction",
107
+ default="both",
108
+ choices=("ancestors", "descendants", "both"),
109
+ )
110
+ lineage_get.add_argument("--depth", type=int, default=2)
111
+ lineage_get.set_defaults(_extract_query="get_lineage")
112
+
113
+ models = sub.add_parser("models", help="Read model registry from a store")
114
+ models_sub = models.add_subparsers(dest="query_action")
115
+ models_list = models_sub.add_parser("list", help="List registered models")
116
+ _add_common(models_list)
117
+ models_list.add_argument("--name-prefix", default="", help="Model name prefix")
118
+ models_list.add_argument("--limit", type=int, default=50, help="Max rows (max 500)")
119
+ models_list.set_defaults(_extract_query="list_models")
120
+
121
+
122
+ def _json_error(code: str, message: str) -> str:
123
+ return json.dumps(
124
+ {"error": {"code": code, "message": message}}, indent=2, sort_keys=True
125
+ )
126
+
127
+
128
+ def _build_filters(args: argparse.Namespace) -> dict[str, str]:
129
+ filters: dict[str, str] = {}
130
+ for attr, key in (
131
+ ("tag", "tag"),
132
+ ("status", "status"),
133
+ ("experiment_prefix", "experiment_prefix"),
134
+ ("started_after", "started_after"),
135
+ ("started_before", "started_before"),
136
+ ):
137
+ value = getattr(args, attr, None)
138
+ if value is not None:
139
+ filters[key] = value
140
+ return filters
141
+
142
+
143
+ def _call_query(args: argparse.Namespace) -> Any:
144
+ import extract.mcp as mcp_mod
145
+
146
+ action = args._extract_query
147
+ if action == "list_experiments":
148
+ return mcp_mod.list_experiments(
149
+ prefix=args.prefix,
150
+ limit=args.limit,
151
+ include_archived=args.include_archived,
152
+ )
153
+ if action == "list_runs":
154
+ return mcp_mod.list_runs(
155
+ experiment_id=args.experiment_id,
156
+ limit=args.limit,
157
+ include_archived=args.include_archived,
158
+ )
159
+ if action == "get_run":
160
+ return mcp_mod.get_run(args.run_id)
161
+ if action == "compare_runs":
162
+ return mcp_mod.compare_runs(args.run_ids, include_curves=args.include_curves)
163
+ if action == "search":
164
+ return mcp_mod.search(
165
+ query=args.query,
166
+ filters=_build_filters(args),
167
+ limit=args.limit,
168
+ include_archived=args.include_archived,
169
+ )
170
+ if action == "list_todos":
171
+ return mcp_mod.list_todos(
172
+ scope_type=args.scope_type,
173
+ scope_id=args.scope_id,
174
+ include_done=args.include_done,
175
+ limit=args.limit,
176
+ )
177
+ if action == "get_lineage":
178
+ return mcp_mod.get_lineage(
179
+ node_type=args.node_type,
180
+ node_id=args.node_id,
181
+ direction=args.direction,
182
+ depth=args.depth,
183
+ )
184
+ if action == "list_models":
185
+ return mcp_mod.list_models(name_prefix=args.name_prefix, limit=args.limit)
186
+ raise RuntimeError(f"unknown query action: {action}")
187
+
188
+
189
+ def run_query_command(args: argparse.Namespace) -> int | None:
190
+ """Run a query command if args selects one; else return None."""
191
+ if not hasattr(args, "_extract_query"):
192
+ return None
193
+
194
+ store_path = Path(args.store)
195
+ if not store_path.exists():
196
+ print(
197
+ _json_error(
198
+ "store_not_found",
199
+ f"store not found: {store_path} — run training with extract-tracker first, or pass --store",
200
+ ),
201
+ file=sys.stdout,
202
+ )
203
+ return 2
204
+
205
+ import extract.mcp as mcp_mod
206
+
207
+ store: Store | None = None
208
+ try:
209
+ store = Store(store_path)
210
+ mcp_mod._store = store
211
+ result = _call_query(args)
212
+ print(json.dumps(result, indent=2, sort_keys=True))
213
+ return 0
214
+ except ValueError as exc:
215
+ print(_json_error("invalid_request", str(exc)), file=sys.stdout)
216
+ return 1
217
+ except Exception as exc: # pragma: no cover - defensive CLI boundary
218
+ print(_json_error("internal_error", str(exc)), file=sys.stdout)
219
+ return 70
220
+ finally:
221
+ mcp_mod._store = None
222
+ if store is not None:
223
+ store.close()
@@ -12,9 +12,9 @@ import json
12
12
  import sys
13
13
  from pathlib import Path
14
14
 
15
- from mcp.server.fastmcp import FastMCP
16
-
15
+ import tomli
17
16
  from extract.store import Store
17
+ from mcp.server.fastmcp import FastMCP
18
18
 
19
19
  # Module-level state. Set by main() at startup; monkey-patched by tests.
20
20
  _store: Store | None = None
@@ -31,8 +31,17 @@ def _tool(fn):
31
31
  # ----------------------------------------------------------------------
32
32
 
33
33
  _MIN_METRIC_PATTERNS = (
34
- "loss", "error", "perplexity", "mse", "mae", "rmse",
35
- "nll", "cer", "wer", "fid", "divergence",
34
+ "loss",
35
+ "error",
36
+ "perplexity",
37
+ "mse",
38
+ "mae",
39
+ "rmse",
40
+ "nll",
41
+ "cer",
42
+ "wer",
43
+ "fid",
44
+ "divergence",
36
45
  )
37
46
 
38
47
 
@@ -70,8 +79,18 @@ def _flatten_config(config: dict, prefix: str = "") -> dict:
70
79
  return result
71
80
 
72
81
 
73
- def _metric_direction(name: str) -> str:
74
- """Return 'min' if metric name matches a minimize pattern, else 'max'."""
82
+ def _metric_direction(
83
+ name: str,
84
+ overrides: tuple[set[str], set[str]] | None = None,
85
+ ) -> str:
86
+ """Return 'min' or 'max' using config overrides, then heuristics."""
87
+ if overrides is not None:
88
+ minimize, maximize = overrides
89
+ if name in minimize:
90
+ return "min"
91
+ if name in maximize:
92
+ return "max"
93
+
75
94
  lowered = name.lower()
76
95
  for pat in _MIN_METRIC_PATTERNS:
77
96
  if pat in lowered:
@@ -79,6 +98,35 @@ def _metric_direction(name: str) -> str:
79
98
  return "max"
80
99
 
81
100
 
101
+ def _metric_direction_overrides(store: Store) -> tuple[set[str], set[str]]:
102
+ """Read [metrics] minimize/maximize overrides from config.toml."""
103
+ config_path = store.root / "config.toml"
104
+ if not config_path.exists():
105
+ return set(), set()
106
+
107
+ with config_path.open("rb") as f:
108
+ data = tomli.load(f)
109
+
110
+ metrics = data.get("metrics", {})
111
+ if not isinstance(metrics, dict):
112
+ return set(), set()
113
+
114
+ minimize = metrics.get("minimize", [])
115
+ maximize = metrics.get("maximize", [])
116
+ return (
117
+ (
118
+ {m for m in minimize if isinstance(m, str)}
119
+ if isinstance(minimize, list)
120
+ else set()
121
+ ),
122
+ (
123
+ {m for m in maximize if isinstance(m, str)}
124
+ if isinstance(maximize, list)
125
+ else set()
126
+ ),
127
+ )
128
+
129
+
82
130
  def _config_diffs(runs_configs: list[tuple[str, dict]]) -> dict:
83
131
  """Return {flat_key: {run_id: value}} for keys that differ across runs.
84
132
 
@@ -96,8 +144,9 @@ def _config_diffs(runs_configs: list[tuple[str, dict]]) -> dict:
96
144
  result: dict = {}
97
145
  for key in all_keys:
98
146
  values = [(rid, flat.get(key, _MISSING)) for rid, flat in flattened]
99
- distinct = {id(v) if v is _MISSING else (type(v).__name__, repr(v))
100
- for _, v in values}
147
+ distinct = {
148
+ id(v) if v is _MISSING else (type(v).__name__, repr(v)) for _, v in values
149
+ }
101
150
  if len(distinct) > 1:
102
151
  result[key] = {rid: v for rid, v in values if v is not _MISSING}
103
152
  return result
@@ -193,14 +242,16 @@ def list_experiments(
193
242
  f"SELECT COUNT(*) FROM runs WHERE experiment_id = ?{run_filter}",
194
243
  (row["id"],),
195
244
  ).fetchone()
196
- items.append({
197
- "id": row["id"],
198
- "path": row["path"],
199
- "name": row["name"],
200
- "node_type": row["node_type"],
201
- "parent_id": row["parent_id"],
202
- "n_runs": n_runs_row[0],
203
- })
245
+ items.append(
246
+ {
247
+ "id": row["id"],
248
+ "path": row["path"],
249
+ "name": row["name"],
250
+ "node_type": row["node_type"],
251
+ "parent_id": row["parent_id"],
252
+ "n_runs": n_runs_row[0],
253
+ }
254
+ )
204
255
 
205
256
  return _listing(items, total=len(items), limit=limit, limit_clamped=clamped)
206
257
 
@@ -257,23 +308,25 @@ def list_runs(
257
308
  for row in rows:
258
309
  config_dict = json.loads(row["config"]) if row["config"] else {}
259
310
  top_keys = list(config_dict.keys())
260
- items.append({
261
- "id": row["id"],
262
- "label": _label(row["experiment_path"], row["name"], row["id"]),
263
- "experiment_id": row["experiment_id"],
264
- "experiment_path": row["experiment_path"],
265
- "name": row["name"],
266
- "status": row["status"],
267
- "started_at": row["started_at"],
268
- "ended_at": row["ended_at"],
269
- "tags": json.loads(row["tags"]) if row["tags"] else [],
270
- "git_sha": row["git_sha"],
271
- "hostname": row["hostname"],
272
- "config_summary": {
273
- "n_keys": len(top_keys),
274
- "top_level_keys": top_keys,
275
- },
276
- })
311
+ items.append(
312
+ {
313
+ "id": row["id"],
314
+ "label": _label(row["experiment_path"], row["name"], row["id"]),
315
+ "experiment_id": row["experiment_id"],
316
+ "experiment_path": row["experiment_path"],
317
+ "name": row["name"],
318
+ "status": row["status"],
319
+ "started_at": row["started_at"],
320
+ "ended_at": row["ended_at"],
321
+ "tags": json.loads(row["tags"]) if row["tags"] else [],
322
+ "git_sha": row["git_sha"],
323
+ "hostname": row["hostname"],
324
+ "config_summary": {
325
+ "n_keys": len(top_keys),
326
+ "top_level_keys": top_keys,
327
+ },
328
+ }
329
+ )
277
330
 
278
331
  return _listing(items, total=len(items), limit=limit, limit_clamped=clamped)
279
332
 
@@ -308,16 +361,18 @@ def list_models(name_prefix: str = "", limit: int = 50) -> dict:
308
361
 
309
362
  items: list[dict] = []
310
363
  for row in rows:
311
- items.append({
312
- "id": row["id"],
313
- "name": row["name"],
314
- "version": row["version"],
315
- "run_id": row["run_id"],
316
- "framework": row["framework"],
317
- "artifact_path": row["artifact_path"],
318
- "metadata": json.loads(row["metadata"]) if row["metadata"] else None,
319
- "created_at": row["created_at"],
320
- })
364
+ items.append(
365
+ {
366
+ "id": row["id"],
367
+ "name": row["name"],
368
+ "version": row["version"],
369
+ "run_id": row["run_id"],
370
+ "framework": row["framework"],
371
+ "artifact_path": row["artifact_path"],
372
+ "metadata": json.loads(row["metadata"]) if row["metadata"] else None,
373
+ "created_at": row["created_at"],
374
+ }
375
+ )
321
376
 
322
377
  return _listing(items, total=len(items), limit=limit, limit_clamped=clamped)
323
378
 
@@ -429,14 +484,16 @@ def get_run(run_id: str) -> dict:
429
484
  ).fetchall()
430
485
  artifacts: list[dict] = []
431
486
  for a in art_rows:
432
- artifacts.append({
433
- "name": a["name"],
434
- "kind": a["kind"],
435
- "step": a["step"],
436
- "rel_path": a["rel_path"],
437
- "shape": json.loads(a["shape"]) if a["shape"] else None,
438
- "dtype": a["dtype"],
439
- })
487
+ artifacts.append(
488
+ {
489
+ "name": a["name"],
490
+ "kind": a["kind"],
491
+ "step": a["step"],
492
+ "rel_path": a["rel_path"],
493
+ "shape": json.loads(a["shape"]) if a["shape"] else None,
494
+ "dtype": a["dtype"],
495
+ }
496
+ )
440
497
 
441
498
  todo_rows = _store._conn.execute(
442
499
  "SELECT id, content, priority, done, created_at, completed_at "
@@ -470,7 +527,11 @@ def get_run(run_id: str) -> dict:
470
527
 
471
528
  _VALID_STATUS = {"running", "completed", "failed", "archived"}
472
529
  _VALID_FILTERS = {
473
- "tag", "status", "experiment_prefix", "started_after", "started_before",
530
+ "tag",
531
+ "status",
532
+ "experiment_prefix",
533
+ "started_after",
534
+ "started_before",
474
535
  }
475
536
 
476
537
 
@@ -568,23 +629,25 @@ def search(
568
629
  for row in rows:
569
630
  config_dict = json.loads(row["config"]) if row["config"] else {}
570
631
  top_keys = list(config_dict.keys())
571
- items.append({
572
- "id": row["id"],
573
- "label": _label(row["experiment_path"], row["name"], row["id"]),
574
- "experiment_id": row["experiment_id"],
575
- "experiment_path": row["experiment_path"],
576
- "name": row["name"],
577
- "status": row["status"],
578
- "started_at": row["started_at"],
579
- "ended_at": row["ended_at"],
580
- "tags": json.loads(row["tags"]) if row["tags"] else [],
581
- "git_sha": row["git_sha"],
582
- "hostname": row["hostname"],
583
- "config_summary": {
584
- "n_keys": len(top_keys),
585
- "top_level_keys": top_keys,
586
- },
587
- })
632
+ items.append(
633
+ {
634
+ "id": row["id"],
635
+ "label": _label(row["experiment_path"], row["name"], row["id"]),
636
+ "experiment_id": row["experiment_id"],
637
+ "experiment_path": row["experiment_path"],
638
+ "name": row["name"],
639
+ "status": row["status"],
640
+ "started_at": row["started_at"],
641
+ "ended_at": row["ended_at"],
642
+ "tags": json.loads(row["tags"]) if row["tags"] else [],
643
+ "git_sha": row["git_sha"],
644
+ "hostname": row["hostname"],
645
+ "config_summary": {
646
+ "n_keys": len(top_keys),
647
+ "top_level_keys": top_keys,
648
+ },
649
+ }
650
+ )
588
651
 
589
652
  return _listing(items, total=len(items), limit=limit, limit_clamped=clamped)
590
653
 
@@ -617,7 +680,9 @@ def compare_runs(run_ids: list[str], include_curves: bool = False) -> dict:
617
680
  }
618
681
  """
619
682
  if len(run_ids) < 2:
620
- raise ValueError(f"compare_runs requires at least 2 run_ids (got {len(run_ids)})")
683
+ raise ValueError(
684
+ f"compare_runs requires at least 2 run_ids (got {len(run_ids)})"
685
+ )
621
686
  if len(run_ids) > 10:
622
687
  raise ValueError(
623
688
  f"compare_runs supports at most 10 runs per call (got {len(run_ids)})"
@@ -627,7 +692,9 @@ def compare_runs(run_ids: list[str], include_curves: bool = False) -> dict:
627
692
  runs_out: list[dict] = []
628
693
  configs: list[tuple[str, dict]] = []
629
694
  metric_values: dict[str, dict[str, float]] = {} # name -> {run_id: final_val}
630
- curves_out: dict[str, dict[str, list[list]]] = {} # name -> {run_id: [[step, value], ...]}
695
+ curves_out: dict[str, dict[str, list[list]]] = (
696
+ {}
697
+ ) # name -> {run_id: [[step, value], ...]}
631
698
 
632
699
  with _store.lock:
633
700
  for rid in run_ids:
@@ -640,12 +707,14 @@ def compare_runs(run_ids: list[str], include_curves: bool = False) -> dict:
640
707
  if row is None:
641
708
  raise ValueError(f"Run not found: {rid!r}")
642
709
 
643
- runs_out.append({
644
- "id": row["id"],
645
- "label": _label(row["experiment_path"], row["name"], row["id"]),
646
- "experiment_path": row["experiment_path"],
647
- "status": row["status"],
648
- })
710
+ runs_out.append(
711
+ {
712
+ "id": row["id"],
713
+ "label": _label(row["experiment_path"], row["name"], row["id"]),
714
+ "experiment_path": row["experiment_path"],
715
+ "status": row["status"],
716
+ }
717
+ )
649
718
  cfg = json.loads(row["config"]) if row["config"] else {}
650
719
  configs.append((rid, cfg))
651
720
 
@@ -671,13 +740,15 @@ def compare_runs(run_ids: list[str], include_curves: bool = False) -> dict:
671
740
  )
672
741
 
673
742
  # Build the metrics dict.
743
+ direction_overrides = _metric_direction_overrides(_store)
674
744
  metrics_out: dict[str, dict] = {}
675
745
  for name, vals in metric_values.items():
676
- direction = _metric_direction(name)
677
- reverse = (direction == "max")
678
- ranking = [rid for rid, _ in sorted(
679
- vals.items(), key=lambda kv: kv[1], reverse=reverse
680
- )]
746
+ direction = _metric_direction(name, direction_overrides)
747
+ reverse = direction == "max"
748
+ ranking = [
749
+ rid
750
+ for rid, _ in sorted(vals.items(), key=lambda kv: kv[1], reverse=reverse)
751
+ ]
681
752
  metrics_out[name] = {
682
753
  "direction": direction,
683
754
  "values": vals,
@@ -767,7 +838,9 @@ def get_lineage(
767
838
  with _store.lock:
768
839
  root_label = _lookup_node_label(_store._conn, node_type, node_id)
769
840
  if root_label is None:
770
- pretty = {"run": "Run", "experiment": "Experiment", "model": "Model"}[node_type]
841
+ pretty = {"run": "Run", "experiment": "Experiment", "model": "Model"}[
842
+ node_type
843
+ ]
771
844
  raise ValueError(f"{pretty} not found: {node_id!r}")
772
845
 
773
846
  visited: set[tuple[str, str]] = {(node_type, node_id)}
@@ -776,7 +849,7 @@ def get_lineage(
776
849
 
777
850
  for _hop in range(depth):
778
851
  next_frontier: list[tuple[str, str]] = []
779
- for (nt, nid) in frontier:
852
+ for nt, nid in frontier:
780
853
  # Descendants: I am the parent.
781
854
  if direction in ("descendants", "both"):
782
855
  rows = _store._conn.execute(
@@ -811,14 +884,19 @@ def get_lineage(
811
884
  seen_edges: set[tuple] = set()
812
885
  unique_edges: list[dict] = []
813
886
  for e in edges_out:
814
- key = (e["parent_type"], e["parent_id"],
815
- e["child_type"], e["child_id"], e["relation"])
887
+ key = (
888
+ e["parent_type"],
889
+ e["parent_id"],
890
+ e["child_type"],
891
+ e["child_id"],
892
+ e["relation"],
893
+ )
816
894
  if key not in seen_edges:
817
895
  seen_edges.add(key)
818
896
  unique_edges.append(e)
819
897
 
820
898
  nodes_out: list[dict] = []
821
- for (nt, nid) in visited:
899
+ for nt, nid in visited:
822
900
  if (nt, nid) == (node_type, node_id):
823
901
  continue # root is emitted separately
824
902
  label = _lookup_node_label(_store._conn, nt, nid)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: extract-tracker
3
- Version: 0.2.2
3
+ Version: 0.2.3
4
4
  Summary: Local-first experiment tracking for deep learning
5
5
  Author: Phil Oh
6
6
  Keywords: experiment-tracking,machine-learning,deep-learning,sqlite,tui,mcp
@@ -10,6 +10,7 @@ manual/tui.md
10
10
  manual/usage.md
11
11
  python/src/extract/__init__.py
12
12
  python/src/extract/__main__.py
13
+ python/src/extract/cli_query.py
13
14
  python/src/extract/experiment.py
14
15
  python/src/extract/init.py
15
16
  python/src/extract/mcp.py
@@ -592,7 +592,7 @@ dependencies = [
592
592
 
593
593
  [[package]]
594
594
  name = "extract-tui"
595
- version = "0.2.2"
595
+ version = "0.2.3"
596
596
  dependencies = [
597
597
  "chrono",
598
598
  "clap",
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "extract-tui"
3
- version = "0.2.2"
3
+ version = "0.2.3"
4
4
  edition = "2024"
5
5
 
6
6
  [dependencies]