Dependency Resolution¶
Cursus does not ask you to hand-wire step inputs to step outputs. Instead, each step
declares — in its .step.yaml interface — the dependencies it consumes and the
outputs it produces, as typed, named, semantically-annotated ports. At compile time a
single component, the UnifiedDependencyResolver, reads those declarations and
infers the wiring: for every dependency of every step it scores each candidate output
across the pipeline and binds the best-scoring one, provided the score clears a fixed
threshold.
This page explains how that inference works: the resolver, the six-component
compatibility score, the SemanticMatcher, the role of compatible_sources, the 0.5
threshold, and how one DAG edge (producer output → consumer dependency) is scored and
turned into a runtime property reference. It also covers cursus dag resolve — the CLI
that runs the real resolver so you can inspect a single edge’s score — and the
silent-misresolution hardening that keeps a wrong-but-plausible match from binding
quietly.
See also: Compilation, the CLI reference, the Step catalog, and the MCP tools.
What gets resolved¶
A step interface declares two relevant sections. On the consumer side, each
dependency carries a type, a required flag, a compatible_sources list, and
semantic_keywords. On the producer side, each output carries a type, a
property_path (the SageMaker runtime property to read), aliases, and
semantic_keywords. Here is an (abbreviated) fragment from
src/cursus/steps/interfaces/xgboost_training.step.yaml — the shipped file lists several
more compatible_sources and semantic_keywords entries:
dependencies:
input_path:
type: training_data
required: true
compatible_sources:
- TabularPreprocessing
- StratifiedSampling
- RiskTableMapping
- ProcessingStep
semantic_keywords:
- data
- input
- training
- dataset
outputs:
model_output:
type: model_artifacts
property_path: properties.ModelArtifacts.S3ModelArtifacts
aliases:
- ModelArtifacts
- model_data
data_type: S3Uri
These decls surface on the loaded interface as DependencyDecl and OutputDecl
objects (in src/cursus/core/base/step_interface.py). Each exposes the attributes the
resolver reads: a dependency has logical_name, dependency_type (an alias of type,
returning a DependencyType enum), data_type, required, compatible_sources, and
semantic_keywords; an output has logical_name, output_type, data_type,
aliases, property_path, and semantic_keywords. logical_name is auto-populated
from the YAML dict key.
DependencyType is a closed enum (src/cursus/core/base/enums.py):
MODEL_ARTIFACTS, PROCESSING_OUTPUT, TRAINING_DATA, HYPERPARAMETERS,
PAYLOAD_SAMPLES, CUSTOM_PROPERTY.
The UnifiedDependencyResolver¶
The resolver lives in src/cursus/core/deps/dependency_resolver.py. It is constructed
from two collaborators:
from cursus.core.deps.dependency_resolver import create_dependency_resolver
from cursus.core.deps.specification_registry import SpecificationRegistry
registry = SpecificationRegistry()
registry.register("XGBoostTraining", xgboost_spec)
registry.register("TabularPreprocessing", preproc_spec)
resolver = create_dependency_resolver(registry) # attaches a SemanticMatcher
SpecificationRegistryholds the registered specs keyed by step name and answersget_specification(step_name).SemanticMatchercomputes name-similarity scores (see below).
The resolver caches per-step results in _resolution_cache; registering a new spec via
register_specification clears the cache.
Resolution entry points¶
The resolver exposes several methods, all of which reduce to the same per-edge scoring:
Method |
Returns |
Used by |
|---|---|---|
|
|
Reports, ad-hoc resolution |
|
|
Batch resolution |
|
|
Validation, |
|
A debug report with per-step resolved/unresolved details and an overall |
Diagnostics |
A PropertyReference (src/cursus/core/deps/property_reference.py) pairs the producer
step_name with the matched output_spec; its to_runtime_property() later becomes an
actual SageMaker Properties object at build time.
The six-component compatibility score¶
For a single (dependency, output) pair the resolver computes one number in [0, 1]
via _calculate_compatibility(dep_spec, output_spec, provider_spec). Six weighted
components contribute. A parallel _get_score_breakdown returns each component
separately for the failed-edge report; note that its source-compatibility component
reports the reward-only form (0.1 if the provider is in compatible_sources, else
0.0): it still normalizes the provider’s job-type suffix
(_normalize_step_type_for_compatibility), but applies neither the −0.1 penalty nor
the _provider_in_compatible_sources alias/legacy-Step reconciliation that
_calculate_compatibility uses.
# |
Component |
Max weight |
How it is computed |
|---|---|---|---|
1 |
Dependency-type compatibility |
0.40 |
|
2 |
Data-type compatibility |
0.20 |
|
3 |
Semantic name matching |
0.25 |
|
4 |
Exact-name / alias bonus |
0.05 |
|
5 |
Compatible-source check |
±0.10 |
|
6 |
Keyword matching |
0.05 |
fraction of |
The final score is min(sum, 1.0).
Type compatibility is a hard gate¶
Component 1 is decisive: _are_types_compatible consults a fixed matrix and if the
dependency type and output type do not overlap, _calculate_compatibility returns 0.0
immediately — no amount of name similarity can rescue an incompatible type. The matrix
allows a few cross-type pairs, e.g. TRAINING_DATA accepts PROCESSING_OUTPUT (so a
preprocessing step can feed a trainer), and HYPERPARAMETERS accepts CUSTOM_PROPERTY.
Data-type compatibility¶
_are_data_types_compatible allows near-equivalents: S3Uri may be used as String
and vice versa, Integer as Float and vice versa, Boolean only as Boolean. An
unknown data type only matches itself.
The SemanticMatcher¶
SemanticMatcher (src/cursus/core/deps/semantic_matcher.py) supplies component 3 — a
name-similarity score in [0, 1]. calculate_similarity(name1, name2) first normalizes
both names (lowercase, split on _/-/., drop special characters, expand
abbreviations such as config→configuration and pkg→package, strip stop words),
returns 1.0 on an exact normalized match, and otherwise blends four sub-metrics:
Sub-metric |
Weight |
Idea |
|---|---|---|
String similarity |
0.30 |
|
Token overlap |
0.25 |
Jaccard over word tokens |
Semantic similarity |
0.25 |
synonym-aware token matching (e.g. |
Substring matching |
0.20 |
one name contained in the other, or shared word substrings |
calculate_similarity_with_aliases(name, output_spec) runs this against the output’s
logical_name and each alias, keeping the highest — which is why declaring an alias
like model_data on an output can lift a dependency’s semantic score above the
threshold even when the logical names differ. explain_similarity returns the full
sub-metric breakdown for debugging.
compatible_sources and the ±0.10 nudge¶
compatible_sources is the consumer’s declared allowlist of producer step types. It
is deliberately not a hard gate. As the resolver’s own comments record, roughly 40%
of the tokens declared across the shipped interfaces are generic categories or legacy
aliases (ProcessingStep, TrainingStep, PayloadStep, S3Source, UserProvided, …)
that are not real registry step types. Zeroing a score whenever the exact string is
absent would wrongly reject legitimate edges — for instance a provider whose canonical
type is Payload against a declared PayloadStep.
So component 5 rewards an in-list provider (+0.1) and only mildly penalizes an
out-of-list one (−0.1), rather than vetoing it. This disadvantages a
semantically-wrong producer without breaking correct-but-legacy-named matches, and it is
only decisive near the 0.5 threshold — the base type (0.4) + data (0.2) + semantic (0.25) still dominates.
Two helpers make the membership test forgiving:
_normalize_step_type_for_compatibilitystrips job-type suffixes (TabularPreprocessing_Training→TabularPreprocessing) using the registry’s canonical-name functions, so a variant provider still matches its base entry incompatible_sources. It falls back to stripping_Training/_Testing/_Validation/_Calibrationif the registry lookup fails._provider_in_compatible_sourcesreconciles legacy spellings: exact match,StepCatalog.LEGACY_ALIASES(e.g.MIMSPayload→Payload,MIMSPackaging→Package), and a trailing-Stepequivalence, soPayload≡PayloadStep.
The 0.5 threshold¶
The resolver treats 0.5 as the pass mark. In _resolve_single_dependency a candidate
is only collected if confidence > 0.5; in resolve_with_scoring the best candidate is
bound only if best_match["score"] >= 0.5, otherwise it is recorded in
failed_with_scores with the top three candidates and their breakdowns. The threshold
is surfaced in resolution_details["resolution_threshold"] and echoed by the CLI.
When multiple candidates clear the bar, the resolver sorts by score and takes the
highest; alternatives are logged at debug level. A dependency that resolves to nothing
is fatal only if it is required — an unresolved optional dependency is logged and
skipped.
How one DAG edge is scored and bound¶
At compile time the resolver runs inside the PipelineAssembler
(src/cursus/core/assembler/pipeline_assembler.py). Its _propagate_messages method
walks the DAG’s declared edges and, for each (src_step, dst_step) edge, asks the
resolver to score every consumer dependency against every producer output:
for dep_name, dep_spec in dst_builder.spec.dependencies.items():
matches = []
for out_name, out_spec in src_builder.spec.outputs.items():
compatibility = resolver._calculate_compatibility(
dep_spec, out_spec, src_builder.spec
)
if compatibility > 0.5: # same threshold as the resolver
matches.append((out_name, out_spec, compatibility))
if matches:
matches.sort(key=lambda x: x[2], reverse=True)
best = matches[0]
self.step_messages[dst_step][dep_name] = {
"source_step": src_step,
"source_output": best[0],
"match_type": "specification_match",
"compatibility": best[2],
}
The winning (source_step, source_output) is stored in step_messages. Later, when the
assembler instantiates each step, it converts that message into a real runtime property:
it looks up the producer output spec via get_output_by_name_or_alias, builds a
PropertyReference(step_name=src_step, output_spec=output_spec), and calls
to_runtime_property(step_instances) to produce the SageMaker Properties handle that
becomes the consumer’s input channel.
Crucially, the assembler then validates that every required dependency got a match and refuses to fabricate a placeholder if the runtime property cannot be built — see “Silent-misresolution hardening” below.
cursus dag resolve¶
cursus dag resolve (in src/cursus/cli/dag_cli.py) exposes exactly this scoring so you
can inspect an edge without compiling a pipeline. You name two or more steps; the command
loads each one’s .step.yaml interface via load_interface, registers the specs, and
runs the real UnifiedDependencyResolver.resolve_with_scoring over them — the same
weights and threshold the compiler and CI use, with no re-implementation.
# Score every dependency edge among these steps
cursus dag resolve CradleDataLoading TabularPreprocessing XGBoostTraining
# Machine-readable, for gating an author-time check
cursus dag resolve TabularPreprocessing XGBoostTraining --format json
For each dependency of each named step, it reports the best-scoring provider among the
other named steps, the score, and whether the edge resolves (>= 0.5). Text output marks
each edge with a check or cross:
Resolve: CradleDataLoading, TabularPreprocessing, XGBoostTraining
✅ XGBoostTraining.input_path <- TabularPreprocessing (score 1.0, resolves=True)
❌ XGBoostTraining.hyperparameters_s3_uri <- None (score 0.0, resolves=False)
The JSON form emits steps, loaded, load_errors, an edges array, all_edges_resolve,
and threshold: 0.5. Each edge object carries consumer, dependency, provider, score,
and resolves; a failed edge additionally carries required. For a resolved edge the CLI
reports score: 1.0 as a “cleared the threshold” marker; a failed edge reports its best
candidate’s actual score (the per-component score_breakdown is computed internally by the
resolver but is not emitted by the CLI). A separate command, cursus dag validate, checks
structural integrity — cycles, dangling edges, isolated nodes, undeclared edge endpoints —
rather than scoring edges.
The same resolver at compile and in validation¶
There is exactly one resolver implementation, and three surfaces run it:
Compile —
PipelineAssembler._propagate_messagesscores each DAG edge and binds the winning output, as shown above.Validation — the alignment layer’s
dependency_validator.pycallsresolve_with_scoring(canonical_spec_name, available_steps)to report resolved dependencies and to raise aCRITICALissue for any required dependency with no candidate at or above threshold.CLI / authoring —
cursus dag resolve(and the MCP author-step workflow) call the sameresolve_with_scoringso an author can confirm, before compiling, that a new step’s edges will bind. Because it is the production resolver, a green result here is trustworthy.
This single-source-of-truth design means the score you see in cursus dag resolve is the
score the compiler will compute.
Silent-misresolution hardening¶
A resolver that “guesses” is dangerous precisely when it guesses wrong but plausibly — binding an edge to the nearest-looking producer and compiling a pipeline that reads the wrong data at runtime. A series of hardening changes closed these silent-failure paths. The relevant ones for resolution:
Job-type-aware config resolution.
StepCatalog.get_step_infoand the config resolver strip job-type suffixes through the registry-drivennaming.resolve_base_step_name/split_job_type_suffix(guarded by the actual step registry so a real base likeXGBoostModelis never mis-stripped), fixing the catalog-tier miss where a suffixed node (TabularPreprocessing_training) withjob_type=Nonefailed to resolve.job_type=Noneno longer crashes. Four sites instep_catalog/adapters/config_resolver.pywere changed to(getattr(config, "job_type", "") or "").lower(), so a config whosejob_typeis present-but-Noneno longer raisesAttributeErrorthat masked a real DAG↔config mismatch as an opaque “unresolvable node”.Bare-name resolution. A suffix-less DAG node now resolves to the single config keyed with a suffix — node
PercentileModelCalibration→ configPercentileModelCalibration_calibration— but only when that base match is unambiguous: the node must itself be a base step name (checked withsplit_job_type_suffix) and exactly one config whose real step type equals that base may claim it. When several configs share the base and the node carries nojob_type, the resolver does not guess — it defers to the scored matching strategies rather than binding an arbitrary one.compatible_sourcesalias/suffix-awareness — thePayload≡PayloadSteptrailing-Stepequivalence and theLEGACY_ALIASESreconciliation (e.g.MIMSPackaging≡Package) described above, plus the−0.1out-of-list penalty, so a correct-but-legacy-named producer is not silently demoted below threshold and a wrong producer is nudged down.Interface variant loading uses a recursive deep-merge. When a
job_typenames a declared variant,StepInterface.from_yamldeep-merges that variant’sspec/contractoverrides over the base sections recursively, so a variant that restates only a subset of ports (e.g. one that tightens a single optional dependency torequired) overrides just those ports’ fields and keeps the base ports it omits — a shallow{**base, **variant}merge previously dropped every base port a variant happened not to restate. When ajob_typeis requested and the step declares variants but none of them matches,from_yamllogs a warning and falls back to the base spec (it deliberately does not raise: step configs validatejob_typeonly as open lowercase-alphanumeric, so legitimate values likemunged/taggingare expected not to be enumerated variants). The hazard of an under-tightened variant is not masked — it is caught downstream, where the dependency resolver flags an unwired required dependency and_sync_and_alignre-checks contract↔spec alignment, so a base-fallback can only under-tighten an optional port, never hide a missing required one.Validation flags misresolutions as errors, not warnings. In
core/compiler/validation.py,validate_dag_compatibilityruns a node-vs-config cross-check: when a node name encodes a step type (matching a known registry base) but the bound config resolves to a different step type, it records aconfig_errorsentry (→is_valid=False) telling the author to add an explicit config key or ametadata.config_typesmapping. It also loads each node’s interface with its resolvedjob_type— the sameload_interface(step_name, job_type=…)call the builder makes at compile — so an interface-load failure surfaces incursus validate, not only at build time. Routability is checked viahas_builder_provider, so SDK-delegation steps (CradleDataLoading,Registration) whose builders don’t import offline are not false-flagged as unresolvable.No fabricated placeholders at build. When a matched edge’s runtime property cannot be built, or the producer spec has no output named/aliased as expected, the assembler now raises a
ValueErrorinstead of inventing ans3://pipeline-reference/...placeholder that would make an absent input appear present and wire a channel at a nonexistent bucket.
The through-line: an edge either resolves to a real, type-compatible, above-threshold producer output, or the failure is reported loudly — at resolve, validate, or compile — rather than compiled into a wrong pipeline.