Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
86 changes: 80 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,20 +24,94 @@ pip install aimx
- `aimx version`
- `aimx doctor`
- `aimx query`
- `aimx trace`

These commands explain how `aimx` works, show the `aimx` version, and report
whether native Aim is available for passthrough. `aimx query` adds a read-only
CLI for querying metric and image data from a local Aim repository.
whether native Aim is available for passthrough.

Query usage:
`--repo` is optional for owned `query` and `trace` commands and defaults to the
current directory `.`. When provided, it accepts either the repository root,
such as `data`, or the metadata directory itself, such as `data/.aim`.

### `aimx query` — discover and summarise metrics

Queries an Aim repository and shows a grouped table with per-metric statistics
(step count, last value, min/max with step).

```bash
# If your current working directory is the Aim repo root, --repo can be omitted
aimx query metrics "metric.name == 'loss'"

# Rich table (default, colored in terminal)
aimx query metrics "metric.name == 'loss'" --repo data
aimx query images "images" --repo data --json

# Short run hashes are transparently expanded to full hashes
aimx query metrics "run.hash=='eca37394' and metric.name=='loss'" --repo data

# Tab-separated plain text, suitable for awk/grep
aimx query metrics "metric.name == 'loss'" --repo data --oneline

# Structured JSON (nested by run)
aimx query metrics "metric.name == 'loss'" --repo data --json

# Step range filter — statistics recomputed within the window
aimx query metrics "metric.name == 'loss'" --repo data --steps 100:500
aimx query metrics "metric.name == 'loss'" --repo data --steps :50 # first 50 steps
aimx query metrics "metric.name == 'loss'" --repo data --steps 100: # from step 100 onwards

# Combine short hash + step range
aimx query metrics "run.hash=='eca37394' and metric.name=='loss'" --repo data --steps 100:300

# Images
aimx query images "images" --repo data
```

Output modes: `--json` (nested runs→metrics), `--oneline` / `--plain` (tab-separated),
default (rich table). Additional flags: `--steps start:end`, `--no-color`, `--verbose`.

### `aimx trace` — plot or export a metric time series

Fetches the full value sequence for one or more metrics and renders a curve,
table, or structured export. Multiple matching runs are overlaid on the same plot.

```bash
# If your current working directory is the Aim repo root, --repo can be omitted
aimx trace "metric.name=='loss'"

# Plot loss curve for a specific run — short hash transparently expanded
aimx trace "run.hash=='eca37394' and metric.name=='loss'" --repo data

# Compare train vs val loss across all runs
aimx trace "metric.name=='loss'" --repo data

# Step-by-step table
aimx trace "metric.name=='loss'" --repo data --table

# CSV export
aimx trace "metric.name=='loss'" --repo data --csv > loss.csv

# JSON with full value arrays
aimx trace "metric.name=='loss'" --repo data --json

# Step range filter (hard constraint, applied before sampling)
aimx trace "metric.name=='loss'" --repo data --steps 100:500
aimx trace "metric.name=='loss'" --repo data --steps :50 # first 50 steps
aimx trace "metric.name=='loss'" --repo data --steps 100: # step 100 onwards

# Combine step filter + JSON
aimx trace "run.hash=='eca37394' and metric.name=='loss'" --repo data --steps 1:200 --json

# Limit to first 50 points per series (density subsampling, applied after --steps)
aimx trace "metric.name=='loss'" --repo data --head 50

# Sample every 10th point
aimx trace "metric.name=='loss'" --repo data --every 10
```

`--repo` accepts either the repository root, such as `data`, or the metadata
directory itself, such as `data/.aim`.
Output modes: default (plotext chart), `--table`, `--csv`, `--json`.
Step filtering: `--steps start:end` (inclusive, open-ended sides allowed).
Sampling: `--head N`, `--tail N`, `--every K`.
Display: `--width W`, `--height H`, `--no-color`.

## What aimx delegates

Expand Down
6 changes: 5 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,11 @@ version = "0.2.0"
description = "A safe CLI-first companion for native Aim"
readme = "README.md"
requires-python = ">=3.10,<3.13"
dependencies = []
dependencies = [
"numpy>=1.24",
"plotext>=5.3",
"rich>=13.7",
]

[project.scripts]
aimx = "aimx.__main__:main"
Expand Down
1 change: 1 addition & 0 deletions src/aimx/aim_bridge/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
from __future__ import annotations
67 changes: 67 additions & 0 deletions src/aimx/aim_bridge/hash_resolver.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
from __future__ import annotations

import re
from pathlib import Path

try:
from aim import Repo
except ModuleNotFoundError: # aim not installed; errors surface at call time
Repo = None # type: ignore[assignment,misc]

# Matches: run.hash == 'x' run.hash=='x' run.hash == "x" run.hash != 'x'
# Capture groups: (1) operator token (2) quote char (3) hash literal
_HASH_LITERAL_RE = re.compile(
r"""(run\.hash\s*(?:==|!=)\s*)(['"])([0-9a-fA-F]+)\2"""
)

_FULL_HASH_LEN = 32


def resolve_hash_prefixes(expression: str, repo_path: Path) -> str:
"""Rewrite short run.hash literals in *expression* to full hashes.

Rules:
- A literal shorter than ``_FULL_HASH_LEN`` hex chars is treated as a prefix.
- A literal of exactly ``_FULL_HASH_LEN`` chars passes through unchanged.
- Matching is case-insensitive (input normalised to lower-case).
- Ambiguous prefix → ``ValueError`` listing candidate previews.
- No matching run → ``ValueError``.
- No ``run.hash`` literal in *expression* → expression returned as-is
without querying the repository.
"""
if not _HASH_LITERAL_RE.search(expression):
return expression

if Repo is None:
raise RuntimeError(
"`aimx` requires the Python `aim` package in the current environment."
)

repo = Repo(str(repo_path))
all_hashes: list[str] = repo.list_all_runs()

def _replace(m: re.Match[str]) -> str:
operator_token = m.group(1)
quote = m.group(2)
value = m.group(3).lower()

if len(value) >= _FULL_HASH_LEN:
return m.group(0)

candidates = [h for h in all_hashes if h.startswith(value)]

if not candidates:
raise ValueError(
f"Short hash '{m.group(3)}' did not match any run in the repository."
)
if len(candidates) > 1:
preview = ", ".join(c[:12] for c in candidates[:5])
suffix = f" (+{len(candidates) - 5} more)" if len(candidates) > 5 else ""
raise ValueError(
f"Short hash '{m.group(3)}' is ambiguous — matches {len(candidates)} runs: "
f"{preview}{suffix}. Provide more characters."
)

return f"{operator_token}{quote}{candidates[0]}{quote}"

return _HASH_LITERAL_RE.sub(_replace, expression)
Loading
Loading