Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
be50f94
feat: add roar tag command (P1 — local storage)
Jul 2, 2026
8038ecf
fix(lint): sort imports and flatten nested with statements in tag tests
Jul 2, 2026
4f02e4a
style: apply ruff format to all new tag files
Jul 2, 2026
cbe53cb
feat: propagate compliance tags from job inputs to outputs
Jul 6, 2026
6b3ebf8
feat: add --block-tag / --add-tag flags to `roar run` (P2b)
Jul 6, 2026
d90eeac
fix: register `roar tag` in the top-level command registry
Jul 6, 2026
ac243d0
fix: exclude composite leaf/component artifacts from label sync
Jul 7, 2026
67864e0
feat: scope-aware tag propagation + roar tag bind/unbind (draft4)
Jul 8, 2026
15dbdef
feat(tags): add roar tag why provenance walk
chrisgeyertreqs Jul 13, 2026
ab90b11
test(rc/0.4.0): fix 6 failing tests (telemetry env hygiene + register…
chrisgeyertreqs Jul 13, 2026
d6c6398
Merge pull request #215 from treqs/jg/roar-tag-p2
christophergeyer Jul 13, 2026
a67f681
feat(tags): enforce canonical kinds with .roarconfig opt-in
chrisgeyertreqs Jul 13, 2026
725275b
feat(tags): support value-level --block-tag KIND=VALUE
chrisgeyertreqs Jul 13, 2026
885660a
feat(tags): record & replay roar run modifiers for reproduction
chrisgeyertreqs Jul 13, 2026
9f1e22c
feat(show): render tags cleanly + job barriers; hide the bind ledger
chrisgeyertreqs Jul 13, 2026
43dd496
fix(tags): give an actionable error for `tag why @<job>`
chrisgeyertreqs Jul 13, 2026
38f03a3
Merge pull request #232 from treqs/cg/rc-040-test-fixes
christophergeyer Jul 13, 2026
fd8f6ae
Merge branch 'rc/0.4.0' into cg/tag-why
christophergeyer Jul 14, 2026
fdb4965
Merge branch 'rc/0.4.0' into cg/tag-custom-kinds
christophergeyer Jul 14, 2026
3cb41a3
Merge branch 'rc/0.4.0' into cg/tag-block-value
christophergeyer Jul 14, 2026
e5f03e5
Merge branch 'rc/0.4.0' into cg/tag-run-modifiers
christophergeyer Jul 14, 2026
ad51ed7
Merge branch 'rc/0.4.0' into cg/show-tags
christophergeyer Jul 14, 2026
ea8df68
Merge pull request #231 from treqs/cg/tag-why
christophergeyer Jul 14, 2026
2e98d7b
Merge pull request #234 from treqs/cg/tag-block-value
christophergeyer Jul 14, 2026
ba4bf6d
Merge pull request #236 from treqs/cg/show-tags
christophergeyer Jul 14, 2026
c925014
Merge branch '_rc040' into _m233
chrisgeyertreqs Jul 14, 2026
ec74e9c
Merge branch '_rc040' into _m235
chrisgeyertreqs Jul 14, 2026
dcd3e4a
Merge pull request #233 from treqs/cg/tag-custom-kinds
christophergeyer Jul 14, 2026
12754cd
Merge pull request #235 from treqs/cg/tag-run-modifiers
christophergeyer Jul 14, 2026
89f6e0a
Merge origin/main (0.3.7 + #229) into rc/0.4.0
chrisgeyertreqs Jul 15, 2026
10e94d4
chore(release): bump version to 0.4.0
chrisgeyertreqs Jul 17, 2026
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
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ build-backend = "maturin"

[project]
name = "roar-cli"
version = "0.3.7"
version = "0.4.0"
description = "Reproducibility and provenance tracker for ML training pipelines"
authors = [
{ name="TReqs Team", email="info@treqs.ai" }
Expand Down
25 changes: 19 additions & 6 deletions roar/application/publish/register_execution.py
Original file line number Diff line number Diff line change
Expand Up @@ -222,8 +222,15 @@ def register_prepared_lineage(
skip_confirmation: bool,
confirm_callback: Callable[[list[str]], bool] | None,
prepared: PreparedRegisterExecution,
composite_leaf_hashes: frozenset[str] = frozenset(),
) -> RegisterResult:
"""Register already-collected local lineage with GLaaS."""
"""Register already-collected local lineage with GLaaS.

``composite_leaf_hashes`` are true composite-member hashes subsumed into a view
edge (see ``_prepare_view_edges_for_lineage``) — they have no session-scoped edge
on GLaaS, so they're excluded from label sync even though they still appear in
``lineage.artifacts`` for ordinary artifact registration.
"""
self._logger.debug(
"Collected lineage: %d jobs, %d artifacts",
len(lineage.jobs),
Expand Down Expand Up @@ -276,6 +283,12 @@ def register_prepared_lineage(
omit_filter=omit_filter,
)

label_artifacts = (
[a for a in lineage.artifacts if a.get("hash") not in composite_leaf_hashes]
if composite_leaf_hashes
else lineage.artifacts
)

registration_jobs = order_jobs_for_registration(
normalize_jobs_for_registration(lineage.jobs)
)
Expand Down Expand Up @@ -344,7 +357,7 @@ def register_prepared_lineage(
session_id=session_id,
session_hash=finalized_session_hash,
jobs=remote_registration_jobs,
artifacts=lineage.artifacts,
artifacts=label_artifacts,
errors=registration_errors,
)
elif batch_result.jobs_failed == 0 and batch_result.links_failed == 0:
Expand Down Expand Up @@ -391,7 +404,7 @@ def register_prepared_lineage(
session_id=session_id,
session_hash=finalized_session_hash,
jobs=remote_registration_jobs,
artifacts=lineage.artifacts,
artifacts=label_artifacts,
errors=registration_errors,
)
except Exception as e:
Expand All @@ -411,7 +424,7 @@ def register_prepared_lineage(
session_id=session_id,
session_hash=finalized_session_hash,
jobs=remote_registration_jobs,
artifacts=lineage.artifacts,
artifacts=label_artifacts,
errors=registration_errors,
)
else:
Expand Down Expand Up @@ -456,7 +469,7 @@ def register_prepared_lineage(
),
db_ctx=db_ctx,
session_id=session_id,
label_artifacts=lineage.artifacts,
label_artifacts=label_artifacts,
)
else:
batch_result = register_publish_lineage(
Expand All @@ -473,7 +486,7 @@ def register_prepared_lineage(
),
db_ctx=None,
session_id=None,
label_artifacts=lineage.artifacts,
label_artifacts=label_artifacts,
)
registration_errors.extend(batch_result.errors)

Expand Down
24 changes: 17 additions & 7 deletions roar/application/publish/service.py
Original file line number Diff line number Diff line change
Expand Up @@ -496,7 +496,7 @@ def _load_remote_job_uid_mapping(*, roar_dir: Path, session_id: Any) -> dict[str

def _prepare_view_edges_for_lineage(
*, roar_dir: Path, lineage: Any, logger: Any
) -> dict[str, list[Any]]:
) -> tuple[dict[str, list[Any]], set[str]]:
"""Compute consumes/produces view edges per job and prune the subsumed leaves in-place.

A job that read part of a dataset gets a ``consumes`` view edge over its inputs; a job
Expand All @@ -505,15 +505,21 @@ def _prepare_view_edges_for_lineage(
and any anchor linked as a plain input/output — are removed from the bundle so the job
links the dataset *view* (job -> view -> anchor -> leaves), not the loose leaves or the
anchor directly. The anchor composite stays in ``lineage.artifacts`` (already
collected) so it is still registered. Returns ``{job_uid: [view_edge, ...]}`` to push
after the main registration.
collected) so it is still registered.

Returns ``({job_uid: [view_edge, ...]}, composite_leaf_hashes)``: the view edges to
push after the main registration, and the union of true component/leaf hashes (never
the anchor's own hash) subsumed across all jobs — a leaf has no session-scoped edge on
GLaaS, so the caller uses this set to keep leaf label/tag documents out of publish-time
label sync too.
"""
from .view_edges import resolve_view_edges_for_job

jobs = getattr(lineage, "jobs", []) or []
if not jobs:
return {}
return {}, set()
view_edges_by_job: dict[str, list[Any]] = {}
composite_leaf_hashes: set[str] = set()
with create_database_context(roar_dir) as db:
for job in jobs:
if not isinstance(job, dict):
Expand All @@ -530,7 +536,7 @@ def _prepare_view_edges_for_lineage(
hashes = list(job.get(hashes_key) or [])
if not hashes:
continue
resolved, prune = resolve_view_edges_for_job(
resolved, prune, leaf_hashes = resolve_view_edges_for_job(
db_ctx=db, input_hashes=hashes, relation=relation
)
if not resolved:
Expand All @@ -539,9 +545,10 @@ def _prepare_view_edges_for_lineage(
job[hashes_key] = [h for h in hashes if h not in prune]
if isinstance(job.get(items_key), list):
job[items_key] = [i for i in job[items_key] if i.get("hash") not in prune]
composite_leaf_hashes |= leaf_hashes
if edges:
view_edges_by_job[str(job_uid)] = edges
return view_edges_by_job
return view_edges_by_job, composite_leaf_hashes


def register_lineage_target(request: RegisterLineageRequest) -> RegisterLineageResponse:
Expand Down Expand Up @@ -628,13 +635,15 @@ def register_lineage_target(request: RegisterLineageRequest) -> RegisterLineageR
# bloom over the touched leaves) and prune the subsumed leaves from the bundle.
# Pushed after the main registration. Best-effort.
view_edges_by_job: dict[str, list[Any]] = {}
composite_leaf_hashes: frozenset[str] = frozenset()
if not request.dry_run:
try:
view_edges_by_job = _prepare_view_edges_for_lineage(
view_edges_by_job, leaf_hashes = _prepare_view_edges_for_lineage(
roar_dir=request.roar_dir,
lineage=collected_lineage.lineage,
logger=logger,
)
composite_leaf_hashes = frozenset(leaf_hashes)
except Exception as exc: # view-edge prep is best-effort
logger.debug("view-edge preparation skipped: %s", exc)

Expand Down Expand Up @@ -728,6 +737,7 @@ def register_lineage_target(request: RegisterLineageRequest) -> RegisterLineageR
skip_confirmation=request.skip_confirmation,
confirm_callback=request.confirm_callback,
prepared=prepared,
composite_leaf_hashes=composite_leaf_hashes,
)

# Push the consumes view edges now that the jobs + the anchor composite are
Expand Down
21 changes: 15 additions & 6 deletions roar/application/publish/view_edges.py
Original file line number Diff line number Diff line change
Expand Up @@ -115,16 +115,23 @@ def resolve_view_edges_for_job(
db_ctx: Any,
input_hashes: list[str],
relation: Literal["consumes", "produces"] = "consumes",
) -> tuple[list[dict[str, Any]], set[str]]:
) -> tuple[list[dict[str, Any]], set[str], set[str]]:
"""Resolve a job's leaf *hashes* into view edges + hashes to prune.

Works from the collected-lineage hashes (not artifact ids). ``relation`` is the side
being resolved: ``consumes`` over a job's input hashes (a run that read part of a
dataset) or ``produces`` over its output hashes (a run that wrote one). The resolution
is identical either way — each leaf is matched to the anchor composite(s) that carry
it as a component. Returns ``(view_edges, prune_hashes)`` — the second being the leaf
hashes that collapse into a view edge plus any anchor composite hash linked as a plain
input/output (the view edge replaces it).
it as a component. Returns ``(view_edges, prune_hashes, leaf_hashes)``:

- ``prune_hashes``: the leaf hashes that collapse into a view edge plus any anchor
composite hash linked as a plain input/output (the view edge replaces it). Used to
drop these from a job's plain input/output edges.
- ``leaf_hashes``: strictly the true component/leaf hashes subsumed into a view edge
(a subset of ``prune_hashes`` that excludes a directly-linked anchor's own hash).
A composite member never has its own session-scoped edge on GLaaS — it has no
standing to carry its own labels/tags either, so callers use this set to keep
per-leaf label/tag documents out of publish-time label sync.

BOUNDARY: matching uses ``find_by_component_digest``, which only knows the anchor's
*stored* components (capped at ``_MAX_STORED_COMPONENTS`` = 1000). For a dataset with
Expand All @@ -138,7 +145,7 @@ def resolve_view_edges_for_job(
artifacts_repo: Any = optional_repo(db_ctx, "artifacts")
composites_repo: Any = optional_repo(db_ctx, "composites")
if artifacts_repo is None or composites_repo is None:
return [], set()
return [], set(), set()

# anchor_id -> {"algorithm": str, "leaves": set, "shards": set}
by_anchor: dict[str, dict[str, Any]] = {}
Expand Down Expand Up @@ -174,6 +181,7 @@ def _attribute(leaf_digest: str, algorithm: str, input_digest: str) -> bool:
_attribute(blake3_digest, "blake3", digest)

view_edges: list[dict[str, Any]] = []
leaf_hashes: set[str] = set()
for anchor_id, bucket in by_anchor.items():
anchor = artifacts_repo.get(anchor_id)
anchor_digest = _primary_composite_digest(anchor)
Expand All @@ -189,8 +197,9 @@ def _attribute(leaf_digest: str, algorithm: str, input_digest: str) -> bool:
)
)
prune |= bucket["shards"]
leaf_hashes |= bucket["shards"]

return view_edges, prune
return view_edges, prune, leaf_hashes


def _primary_composite_digest(artifact: dict[str, Any] | None) -> str | None:
Expand Down
12 changes: 12 additions & 0 deletions roar/application/query/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,10 @@
"LogQueryRequest": ".requests",
"ShowQueryRequest": ".requests",
"StatusQueryRequest": ".requests",
"TagAddRequest": ".requests",
"TagHistoryRequest": ".requests",
"TagRmRequest": ".requests",
"TagShowRequest": ".requests",
"LabelCurrentSummary": ".results",
"LabelHistorySummary": ".results",
"LineageSummary": ".results",
Expand All @@ -37,6 +41,10 @@
"build_show_labels_summary": ".label",
"build_sync_labels_summary": ".label",
"build_unset_labels_summary": ".label",
"build_tag_add_summary": ".tag",
"build_tag_history_summary": ".tag",
"build_tag_rm_summary": ".tag",
"build_tag_show_summary": ".tag",
"copy_labels": ".label",
"label_history": ".label",
"remote_label_history": ".label",
Expand All @@ -51,6 +59,10 @@
"set_labels": ".label",
"show_labels": ".label",
"sync_labels": ".label",
"tag_add": ".tag",
"tag_history": ".tag",
"tag_rm": ".tag",
"tag_show": ".tag",
"unset_labels": ".label",
}

Expand Down
52 changes: 52 additions & 0 deletions roar/application/query/requests.py
Original file line number Diff line number Diff line change
Expand Up @@ -176,3 +176,55 @@ class InputsQueryRequest:
output_json: bool = False
unsourced: bool = False # show only unsourced inputs (no producer)
sourced: bool = False # show only sourced inputs (produced/ingested by roar)


@dataclass(frozen=True)
class TagAddRequest:
roar_dir: Path
cwd: Path
kv: str # "kind=value"
target: str


@dataclass(frozen=True)
class TagRmRequest:
roar_dir: Path
cwd: Path
key_or_kv: str # "kind" or "kind=value"
target: str


@dataclass(frozen=True)
class TagShowRequest:
roar_dir: Path
cwd: Path
target: str


@dataclass(frozen=True)
class TagHistoryRequest:
roar_dir: Path
cwd: Path
target: str


@dataclass(frozen=True)
class TagBindRequest:
roar_dir: Path
cwd: Path
targets: tuple[str, ...]


@dataclass(frozen=True)
class TagUnbindRequest:
roar_dir: Path
cwd: Path
targets: tuple[str, ...]


@dataclass(frozen=True)
class TagWhyRequest:
roar_dir: Path
cwd: Path
target: str
key: str # "kind" or "kind=value" — value narrows the explanation to one value
74 changes: 74 additions & 0 deletions roar/application/query/results.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
from typing import TYPE_CHECKING, Any

if TYPE_CHECKING:
from ..tags import WhyNode
from .diff_graph import JobMatch, JobNode
from .git_readiness import GitReadinessSummary

Expand Down Expand Up @@ -146,6 +147,79 @@ def render(self) -> str:
return "\n\n".join(version.render() for version in self.versions)


@dataclass(frozen=True)
class TagBindArtifactSummary:
"""One target's outcome from `roar tag bind`/`unbind` — what the CLI echoes."""

display_target: str
action: str # "bind" | "unbind"
changed: bool
promoted: dict[str, list[str]] = field(default_factory=dict)
size: int | None = None

def render(self) -> str:
verb = "Bound" if self.action == "bind" else "Unbound"
header = self.display_target
if self.size is not None:
header += f" ({self.size} bytes)"
lines = [f"{verb}: {header}"]
if self.size == 0:
lines.append(
" warning: this is the empty-content hash — shared by every empty "
"file in every session. Binding it promotes its tags everywhere."
)
if not self.changed:
if self.promoted:
lines.append(" no change: already up to date")
else:
noun = "currently bound" if self.action == "unbind" else "to bind"
lines.append(f" no change: nothing {noun}")
return "\n".join(lines)
for kind in sorted(self.promoted):
lines.append(f" {kind}={', '.join(self.promoted[kind])}")
return "\n".join(lines)


@dataclass(frozen=True)
class TagBindSummary:
artifacts: list[TagBindArtifactSummary] = field(default_factory=list)

def render(self) -> str:
if not self.artifacts:
return "No targets."
return "\n".join(artifact.render() for artifact in self.artifacts)


@dataclass(frozen=True)
class TagWhySummary:
"""Renders the provenance walk produced by ``roar tag why``.

``roots`` is a forest of ``WhyNode`` (one tree per explained value); each
node's ``label`` describes one hop back toward a human act (``tag add`` /
``run --add-tag`` / a cross-session ``bind``).
"""

heading: str
roots: list[WhyNode] = field(default_factory=list)
empty_message: str = "(no such tag on target — nothing to explain)"

def render(self) -> str:
lines = [self.heading]
if not self.roots:
lines.append(f" {self.empty_message}")
return "\n".join(lines)
for root in self.roots:
lines.extend(self._render_node(root, depth=0))
return "\n".join(lines)

def _render_node(self, node: WhyNode, *, depth: int) -> list[str]:
indent = " " + " " * depth
lines = [f"{indent}{'└─ ' if depth else ''}{node.label}"]
for child in node.children:
lines.extend(self._render_node(child, depth=depth + 1))
return lines


@dataclass(frozen=True)
class ShowHashSummary:
algorithm: str
Expand Down
Loading
Loading