Skip to content

feat(tangle-cli): add pipeline compile command and PipelineCompiler#28

Merged
Silin144 merged 6 commits into
TangleML:masterfrom
Silin144:feat/pipeline-compile
Jul 15, 2026
Merged

feat(tangle-cli): add pipeline compile command and PipelineCompiler#28
Silin144 merged 6 commits into
TangleML:masterfrom
Silin144:feat/pipeline-compile

Conversation

@Silin144

Copy link
Copy Markdown
Collaborator

Summary

Ports the Python-pipeline compiler from the internal tangle-deploy package into the OSS CLI, so the generic compile logic lives in OSS. Follows the existing PipelineHydrator(TangleCliHandler) architecture and the hydrate facade pattern rather than inventing new conventions.

Follow-up to #20 (the python_pipeline authoring DSL, now merged) — this adds the compile half Andrei flagged as the next step, including subpipeline and @registered compilation.

What's included

  • pipeline_compiler.pyPipelineCompiler(TangleCliHandler) executor plus the generic compile_pipeline() free function. The zone-root discovery seam is a module-level ZONE_ROOT_MARKERS: list[str] = [], left empty in OSS for downstream injection (e.g. tangle-deploy appends its oasis marker).
  • schema_validation.py + bundled schemas/dehydrated_pipeline_schema.json — JSON-Schema (Draft 2020-12) structural validation, semantic checks (dangling taskOutput.taskId, undeclared graphInput.inputName, outputValuesoutputs, pure componentRef), and the no-template-delimiter output contract with exempt_paths.
  • pipelines.compile_pipeline_file functional facade + a cyclopts compile subcommand under tangle sdk pipelines, mirroring hydrate.
  • jsonschema>=4.0.0 runtime dependency (uv.lock updated).
  • Tests for the compiler, the CLI command, and schema validation, with colocated python_pipeline fixtures.

Worked example

$ tangle sdk pipelines compile pipeline.py -o pipeline.yaml
Compiled pipeline.py -> pipeline.yaml (1 task(s)).

Verified on a real downstream pipeline (discovery's llm_cg_large_scrape.py): compiles cleanly into the root pipeline plus its subpipeline child sidecars, with resolve:// component refs preserved.

Testing

  • uv run pytest — compiler / CLI / schema suites green (85 tests across the touched files), no regressions.
  • uv lock --check — clean.
  • git diff --check — no whitespace errors.

Rebased onto master after #20 merged; the diff here is compile-phase-only.

🤖 Generated with Claude Code

@Silin144
Silin144 requested review from Ark-kun and Volv-G as code owners July 14, 2026 16:23
@Silin144 Silin144 self-assigned this Jul 14, 2026
Silin144 added a commit to Silin144/tangle-cli that referenced this pull request Jul 14, 2026
…surface

Second independent review of TangleML#28 surfaced two verified findings; fixing
both here per request:

1. Remove the dead `_resolve_cfg_path(pipeline_fn, module_path)` wrapper in
   pipeline_compiler.py — a thin, zero-caller shim over
   `_resolve_cfg_path_in_dir` (the actual resolver, retained).

2. Scrub internal product/infra names from OSS docstrings + examples so the
   open-source surface never names Oasis / UPI / Comet / areas-ml image paths:
   - task_env.py: example env `UPI`/`areas-ml-upi:main`/`publish_to_comet`/
     `[UPI Clustering]`/"Oasis runner" -> generic `TRAINING`/`python:3.12`/
     `train_model`/"downstream runner".
   - registered.py: relative-path resolution doc referenced the internal
     `oasis.pipeline_component_root.yaml` marker -> now points at the generic
     `ZONE_ROOT_MARKERS` seam and states the OSS build registers none by default.
   - component_from_func.py: 31 example `UPI` env-binding identifiers in the
     authoring-strip docstrings -> `TRAINING`.
   - trace.py: CompileError example `return publish_to_comet` -> `return train_model`.

Widen `TestNoInternalReferences` to enforce the scrub and prevent regression:
alongside the existing tangle_deploy/tangle-deploy substring check it now flags
`areas-ml`/`areas/ml` substrings and the standalone words oasis/upi/comet
(word-boundary, case-insensitive — so it won't false-positive on "deduping").

745 passed; `uv lock --check` and `git diff --check` clean; no new ruff findings
in touched files.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Silin144 added a commit to Silin144/tangle-cli that referenced this pull request Jul 14, 2026
…er it

Second independent review of TangleML#28 found a blocking correctness bug in the
propagate_config broadcast subsystem, plus its total lack of test coverage.
Both fixed here, with two doc nits.

1. BLOCKING — broadcast hard-failed through a config-less intermediate.
   In `_process_subpipeline_children`, an active broadcast (non-empty
   `ctx.broadcast_stack`) unconditionally called `_read_raw_cfg(child_cfg_path)`
   for every child edge, and that helper raises `CompileError: config file not
   found` on a missing file. A pure composing subpipeline (no `cfg` param, no
   `config=`) has no config.yaml on disk, so the canonical shape
   `root(propagate_config=True) -> config-less parent -> config-taking child`
   failed with a spurious "config file not found" for a file the author never
   wrote. Fix: in the broadcast path, treat a MISSING child config as `{}` (the
   child declares nothing to overlay; broadcast keys flow PAST it to
   descendants). A config-DECLARING child whose file is genuinely missing is
   still caught later, with guidance, in `_load_cfg_and_raw` (verified: the
   `cfg`-param error message is preserved).

2. Coverage: the entire broadcast subsystem had zero tests
   (`grep propagate_config tests/` was empty — which is why #1 shipped). Added
   `tests/fixtures/python_pipeline/broadcast_zone/` (a 3-level tree isolated in
   its own dir so the config-less intermediate genuinely has no config.yaml —
   the shared fixtures dir has one that would mask the bug) + a regression test
   asserting the compile succeeds and the root's broadcast value reaches the
   grandchild leaf, winning over the grandchild's own config (three distinct
   values make the source unambiguous).

3. Nit: the "content-hash" wording in a subpipeline test comment is really a
   compile-key hash (source path + qualname + name + config path + overrides
   fingerprint), not a hash of emitted content — corrected.

4. Nit: genericized a docstring example name (`options_standardization` /
   "Options Standardization") that echoed an internal pipeline -> `train_model`
   / "Train Model".

746 passed (+1); ruff clean on touched files; `uv lock --check` and
`git diff --check` clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
# elsewhere in the CLI (e.g. the hydrator's ``COMPONENT_RESOLVERS``): mutate
# the list in place rather than rebinding it, so callers that already imported
# the name observe the addition.
ZONE_ROOT_MARKERS: list[str] = []

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(AI-assisted)

This seam is consistent with the dependency-inversion direction, but it is also a fidelity change from the internal compiler default: relative @registered(gen_config="...") paths no longer work out of the box in OSS because ZONE_ROOT_MARKERS starts empty.

That means _find_zone_root() returns None, and explicit relative gen_config paths are rejected until a downstream package registers its marker. Could we confirm/track that downstream tangle-deploy will register its Oasis marker before existing relative-gen_config pipelines move to the OSS compiler? Otherwise those pipelines will stop compiling even though the rest of registered URL handling is preserved.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed intentional. In OSS ZONE_ROOT_MARKERS is empty by design, so a relative @registered(gen_config=...) path raises instead of silently resolving against an internal zone-root marker. The downstream package restores the internal default by appending its own marker (ZONE_ROOT_MARKERS.append("oasis.pipeline_component_root.yaml")) at import time.

That registration is part of the Phase-3 downstream cutover — it lands alongside the thin re-export shim, before any relative-gen_config pipeline compiles through OSS. Absolute and resolve:// gen_config paths are unaffected either way. Leaving this open for you to close once the Phase-3 marker registration is in.

)


@app.command(name="compile")

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(AI-assisted)

Small fidelity note to confirm: the internal compile CLI exposed a --config / -f config-file wrapper (ArgsContainer-style), while the OSS command here exposes the simpler surface of positional pipeline_path plus required --output (with --pipeline, --override, etc.).

That seems reasonable for OSS, but it is a behavior/API drift from the internal command. Could we confirm this is intentional, and that downstream tangle-deploy will keep/provide any compatibility wrapper its users need?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed intentional. The OSS command exposes a cyclopts-native surface (positional pipeline_path + --output) rather than the internal --config / -f ArgsContainer wrapper. The generic API underneath (compile_pipeline(script, output, overrides=..., pipeline_name=...)) still takes explicit config/override arguments, so the downstream package keeps its existing --config/-f command as a thin wrapper over that API — no capability is dropped, only the CLI arg spelling differs between the two entry points. Leaving open for you to confirm/close.

Comment thread packages/tangle-cli/src/tangle_cli/schema_validation.py

@Volv-G Volv-G left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(AI-assisted) Reviewed the compiler port (code review + a behavior-level fidelity diff against the internal tangle-deploy source, and an empirical compile run).

Approving — this is a faithful, well-architected incremental port. The core compile behavior matches tangle-deploy's original: recursive in-memory compile with validate-all-before-write, subgraph + @task local_from_python sidecars, resolve:// URL rewriting, the no-template-delimiter / raw() output guard, diamond-subpipeline dedup, and the sys.modules purge/eviction. The earlier tangle-deploy-reference + guard-gap finding is already resolved at this head (refs removed, TestNoInternalReferences extended to the compile surface).

One thing to flag: this does not yet work end-to-end on real, legacy-authored Oasis pipelines — and that's expected. A downstream pipeline that imports tangle_deploy.python_pipeline (e.g. discovery's llm_cg_large_scrape.py) currently fails to compile with the OSS tangle sdk pipelines compile (no @pipeline-decorated function found), because the installed tangle_deploy is still the legacy internal DSL — its PipelineFn is a different class than tangle_cli.python_pipeline.PipelineFn, so the OSS tracer doesn't recognize it. E2E compilation of existing pipelines depends on the final Phase-3 PR landing — the one that turns tangle_deploy.python_pipeline into an identity-preserving re-export of the OSS DSL and registers it into the OSS seams (register_authoring_import_module + the zone-root marker for relative @registered(gen_config=…)). Not a blocker for merging this OSS building block.

Non-blocking follow-ups:

  • Verify the bundled schemas/dehydrated_pipeline_schema.json actually ships in the built wheel (compile hard-depends on it at runtime; source-tree CI won't catch a packaging gap). pyproject.toml only adds the jsonschema dep.
  • Relative @registered(gen_config=…) no longer resolves by default (Oasis marker moved behind the empty ZONE_ROOT_MARKERS seam) — depends on the Phase-3 downstream marker registration above.
  • --config/-f / ArgsContainer CLI wrapper from the internal compile command wasn't ported (simpler positional + --output API); assuming intentional.

LGTM to merge as Phase 2; the migration completes e2e when Phase 3 lands.

@danilchenko-andrey danilchenko-andrey left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(AI assisted)
Automated adversarial style pass — nits only, none blocking.

One extra nit that can't be inline-anchored (it's on a file outside this PR's diff):

nit: TestNoInternalReferences scans only the source modules, not tests/fixtures/. A couple of task_env_strip_* fixtures define env names the guard would otherwise flag — consider extending the scan to fixtures and renaming those to generic tokens.

Comment thread packages/tangle-cli/src/tangle_cli/pipeline_compiler.py Outdated
Comment thread packages/tangle-cli/src/tangle_cli/pipeline_compiler.py
Comment thread packages/tangle-cli/src/tangle_cli/schema_validation.py Outdated
@danilchenko-andrey

Copy link
Copy Markdown

(AI assisted)

Possible bug: a real subpipeline cycle escapes precise detection under propagate_config

The compile key for a subpipeline is built in two places that agree only when there is no ambient broadcast context:

  • the child pushes its own key onto active_stack without ambient context — pipeline_compiler.py:378:490
  • the parent builds the child key with ambient context and uses that for the cycle check — :598:609

Under propagate_config with a pass-through key (a broadcast key the child doesn't declare), the parent's key carries an ambient envelope the stacked key lacks, so child_key in active_stack never matches. The cycle slips past the precise check and instead trips the max-depth guard at :622 after 32 redundant levels — and the message advises "Reduce nesting depth", which is misleading for what is actually a cycle.

Minimal repro

repro.py:

from tangle_cli.python_pipeline import In, Out, pipeline, subpipeline


@pipeline("Loop")  # config-less composer: no cfg param, no config=
def loop(seed: In[str]) -> Out[str]:
    # Self-reference => a 1-node cycle.
    return subpipeline(loop).named("Recurse")(seed=seed)


@pipeline("Broadcast Root", config="root_config.yaml", propagate_config=True)
def broadcast_root(parent_wait_token: In[str], cfg) -> Out[str]:
    # Broadcasts shared_key deep; `loop` doesn't declare it, so it flows past
    # loop as ambient context at EVERY self-edge -> the cycle key diverges.
    return subpipeline(loop).named("Run Loop")(seed=parent_wait_token)

root_config.yaml:

shared_key: broadcast-value

Result — same loop cycle, only difference is the broadcasting ancestor

Case Aloop compiled directly (no broadcast):

$ tangle sdk pipelines compile repro.py -o /tmp/loop.yaml --pipeline loop
nested pipeline cycle detected: Loop (repro.py) -> Loop (repro.py).
Recursive Python pipelines are not supported; break the cycle or use a published component boundary.

✅ correct.

Case B — the same loop, reached under the propagate_config root:

$ tangle sdk pipelines compile repro.py -o /tmp/broadcast.yaml --pipeline broadcast_root
nested pipeline max depth 32 exceeded:
  Broadcast Root -> Loop -> Loop -> Loop -> ... -> Loop      (Loop repeated 32x)
Reduce nesting depth or restructure the pipeline graph.

🐞 same cycle, not detected — degrades to max-depth after 32 redundant levels.

Suggested fix

Compute the key once (with ambient) in the parent and thread it into _compile_pipeline_fn, so the pushed/stacked key, the registry key, and the cycle-check key are the same key.

Impact: misleading diagnostic + wasted compile work; not a crash and not a false rejection of a valid pipeline. Confined to the propagate_config path (default-isolation compiles are unaffected — the two keys are byte-identical there).

@Silin144

Copy link
Copy Markdown
Collaborator Author

@andrei — you were right, thanks for pushing on this. It was a real bug, not a misread.

Root cause. A subpipeline's compile key was computed in two divergent places. The parent computes the child's cycle-check/registry key with the ambient pass-through context (broadcast keys the child doesn't declare, which propagate_config folds into the fingerprint). But the child then recomputed its own key — ambient-less — to push onto active_stack. Under propagate_config with a pass-through key the two keys diverge, so child_key in active_stack never matched. The cycle escaped precise detection and degraded to the max-depth-32 guard, which emitted the misleading "Reduce nesting depth" message after 32 redundant levels.

Fix. Thread the parent-computed key into _compile_pipeline_fn as precomputed_key, so the stacked / registry / cycle-check keys are identical. The root has no parent/ambient envelope, so it still builds its own key. (artifact.key is only ever read for .pipeline_name, so nothing downstream cares about the key value changing.)

Before/after on your repro:

  • before: ... max depth 32 exceeded ... Reduce nesting depth
  • after: nested pipeline cycle detected: Broadcast Root -> Loop -> Loop

Tests. Added the suite's first two cycle-detection tests: a direct self-reference, and the propagate_config pass-through regression that reproduces exactly this. Full suite green (748 passed, +2), ruff/lockfile/whitespace clean. Also re-checked a propagate_config diamond to confirm no false-positive cycle and that dedup still collapses the twice-reached node to one sidecar.

Pushed as d1c63c9. Take another look when you get a chance.

Silin144 and others added 6 commits July 15, 2026 16:10
Port the Python-pipeline compiler from the internal tangle-deploy
package into the OSS CLI so the generic compile logic lives in OSS:

- pipeline_compiler.py: PipelineCompiler(TangleCliHandler) executor plus
  the generic compile_pipeline() free function. The zone-root seam
  (ZONE_ROOT_MARKERS) is left empty here for downstream injection.
- schema_validation.py + schemas/dehydrated_pipeline_schema.json:
  Draft 2020-12 structural validation, semantic checks, and the
  no-template-delimiter output contract.
- pipelines.compile_pipeline_file facade and a cyclopts `compile`
  subcommand under `tangle sdk pipelines`, following the hydrate facade.
- jsonschema>=4.0.0 runtime dependency (uv.lock updated).
- Tests for the compiler, CLI command, and schema validation, with
  colocated python_pipeline fixtures.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ead code

Follow-up to the PipelineCompiler port, addressing an independent review of
the PR:

- Add compile tests for the headline features the command is named after:
  subpipeline child-sidecar emission (parent componentRef rewrite, no
  subpipeline://pending leak), .override_config precedence on a subpipeline
  edge, and @registered gen_config resolution (omitted -> nearest ancestor;
  relative rejected when ZONE_ROOT_MARKERS is empty; relative resolved once a
  marker is appended to the seam). New hermetic fixtures under
  tests/fixtures/python_pipeline/.
- Remove the dead _parse_overrides from pipeline_compiler.py; the live copy
  lives in pipelines_cli.py and is covered by the CLI bad-override test.
- Strip remaining internal-tool references from user-facing surfaces: the
  config/output-collision CompileError message, the _fragment_for_task
  docstring, and the _coerce_override docstring in cfg.py.
- Widen the DSL no-internal-references guard to scan the sibling OSS compile
  modules (component_from_func, pipeline_compiler, schema_validation,
  pipelines, pipelines_cli), not just the DSL package.

Full suite: 745 passed. uv lock --check and git diff --check clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…surface

Second independent review of TangleML#28 surfaced two verified findings; fixing
both here per request:

1. Remove the dead `_resolve_cfg_path(pipeline_fn, module_path)` wrapper in
   pipeline_compiler.py — a thin, zero-caller shim over
   `_resolve_cfg_path_in_dir` (the actual resolver, retained).

2. Scrub internal product/infra names from OSS docstrings + examples so the
   open-source surface never names Oasis / UPI / Comet / areas-ml image paths:
   - task_env.py: example env `UPI`/`areas-ml-upi:main`/`publish_to_comet`/
     `[UPI Clustering]`/"Oasis runner" -> generic `TRAINING`/`python:3.12`/
     `train_model`/"downstream runner".
   - registered.py: relative-path resolution doc referenced the internal
     `oasis.pipeline_component_root.yaml` marker -> now points at the generic
     `ZONE_ROOT_MARKERS` seam and states the OSS build registers none by default.
   - component_from_func.py: 31 example `UPI` env-binding identifiers in the
     authoring-strip docstrings -> `TRAINING`.
   - trace.py: CompileError example `return publish_to_comet` -> `return train_model`.

Widen `TestNoInternalReferences` to enforce the scrub and prevent regression:
alongside the existing tangle_deploy/tangle-deploy substring check it now flags
`areas-ml`/`areas/ml` substrings and the standalone words oasis/upi/comet
(word-boundary, case-insensitive — so it won't false-positive on "deduping").

745 passed; `uv lock --check` and `git diff --check` clean; no new ruff findings
in touched files.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…er it

Second independent review of TangleML#28 found a blocking correctness bug in the
propagate_config broadcast subsystem, plus its total lack of test coverage.
Both fixed here, with two doc nits.

1. BLOCKING — broadcast hard-failed through a config-less intermediate.
   In `_process_subpipeline_children`, an active broadcast (non-empty
   `ctx.broadcast_stack`) unconditionally called `_read_raw_cfg(child_cfg_path)`
   for every child edge, and that helper raises `CompileError: config file not
   found` on a missing file. A pure composing subpipeline (no `cfg` param, no
   `config=`) has no config.yaml on disk, so the canonical shape
   `root(propagate_config=True) -> config-less parent -> config-taking child`
   failed with a spurious "config file not found" for a file the author never
   wrote. Fix: in the broadcast path, treat a MISSING child config as `{}` (the
   child declares nothing to overlay; broadcast keys flow PAST it to
   descendants). A config-DECLARING child whose file is genuinely missing is
   still caught later, with guidance, in `_load_cfg_and_raw` (verified: the
   `cfg`-param error message is preserved).

2. Coverage: the entire broadcast subsystem had zero tests
   (`grep propagate_config tests/` was empty — which is why #1 shipped). Added
   `tests/fixtures/python_pipeline/broadcast_zone/` (a 3-level tree isolated in
   its own dir so the config-less intermediate genuinely has no config.yaml —
   the shared fixtures dir has one that would mask the bug) + a regression test
   asserting the compile succeeds and the root's broadcast value reaches the
   grandchild leaf, winning over the grandchild's own config (three distinct
   values make the source unambiguous).

3. Nit: the "content-hash" wording in a subpipeline test comment is really a
   compile-key hash (source path + qualname + name + config path + overrides
   fingerprint), not a hash of emitted content — corrected.

4. Nit: genericized a docstring example name (`options_standardization` /
   "Options Standardization") that echoed an internal pipeline -> `train_model`
   / "Train Model".

746 passed (+1); ruff clean on touched files; `uv lock --check` and
`git diff --check` clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…om docstring, extract cfg-load helper

Reviewer follow-ups on the PR:

* pipeline_compiler.py — replace ~36 internal design-doc citations
  (PROPAGATE_CONFIG_DESIGN §N, Decision A–M, NM1/NM2, Fix B, Finding 2,
  P2/D4/Phase N) with the rule stated inline, so every comment stands on
  its own in the public repo with no dangling pointer to a doc that
  isn't published.
* schema_validation.py — delete the module-docstring sentence promising a
  `PipelineHydrator.validate_dehydrated_file` convenience that does not
  exist.
* pipeline_compiler.py — extract the step-1 cfg resolve/load block (plus
  its warning/override-error branches) out of `_compile_pipeline_fn` into
  `_resolve_cfg_for_pipeline`, leaving the compile function a thin
  orchestrator whose remaining steps already delegate to named passes.

Comment/docstring-only plus pure code-motion; behavior unchanged
(746 tests pass, golden byte-identical fixtures included).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… child

Under propagate_config, a broadcast key a child does not declare flows past it
as ambient pass-through context, which the parent folds into the child's
cycle-check/registry key. The child then recomputed its OWN key (ambient-less)
to push onto active_stack, so `child_key in active_stack` never matched: a real
cycle escaped precise detection and degraded to the max-depth-32 guard with a
misleading "Reduce nesting depth" message.

Thread the parent-computed key into _compile_pipeline_fn as `precomputed_key`
so the stacked, registry, and cycle-check keys are identical; the root still
builds its own (ambient-less) key. Adds the suite's first cycle-detection
tests: a direct self-reference and the propagate_config pass-through regression.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@Silin144
Silin144 force-pushed the feat/pipeline-compile branch from d1c63c9 to 53acb14 Compare July 15, 2026 20:13

@danilchenko-andrey danilchenko-andrey left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Now looks good to me, thank you!

@Silin144
Silin144 merged commit 874a252 into TangleML:master Jul 15, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants