diff --git a/pipelex/builder/agentic_builder.mthds b/pipelex/builder/agentic_builder.mthds index bba0bf1fb..22768126d 100644 --- a/pipelex/builder/agentic_builder.mthds +++ b/pipelex/builder/agentic_builder.mthds @@ -13,7 +13,7 @@ inputs = { plan_draft = "builder.PlanDraft", pipe_signatures = "pipe_design.Pipe output = "pipe_design.PipeSpec[]" input_list_name = "pipe_signatures" input_item_name = "pipe_signature" -branch_pipe_code = "detail_pipe_spec" +branch_pipe_code = "pipe_design.detail_pipe_spec" # Main agent builder: from flow to bundle (skips all drafting) [pipe.build_from_flow] @@ -22,8 +22,8 @@ description = "Build a complete PipelexBundleSpec from pre-generated flow and co inputs = { brief = "builder.UserBrief", plan_draft = "builder.PlanDraft", prepared_flow = "builder.FlowDraft", concept_specs = "builder.ConceptSpec[]" } output = "builder.PipelexBundleSpec" steps = [ - { pipe = "design_pipe_signatures", result = "pipe_signatures" }, - { pipe = "write_bundle_header", result = "bundle_header_spec" }, + { pipe = "builder.design_pipe_signatures", result = "pipe_signatures" }, + { pipe = "builder.write_bundle_header", result = "bundle_header_spec" }, { pipe = "detail_all_pipe_specs", result = "pipe_specs" }, - { pipe = "assemble_pipelex_bundle_spec", result = "pipelex_bundle_spec" } + { pipe = "builder.assemble_pipelex_bundle_spec", result = "pipelex_bundle_spec" } ] diff --git a/pipelex/builder/builder.mthds b/pipelex/builder/builder.mthds index 043a7f18a..e77d89a1d 100644 --- a/pipelex/builder/builder.mthds +++ b/pipelex/builder/builder.mthds @@ -28,7 +28,7 @@ steps = [ { pipe = "review_flow", result = "prepared_flow" }, { pipe = "design_pipe_signatures", result = "pipe_signatures" }, { pipe = "write_bundle_header", result = "bundle_header_spec" }, - { pipe = "detail_pipe_spec", batch_over = "pipe_signatures", batch_as = "pipe_signature", result = "pipe_specs" }, + { pipe = "pipe_design.detail_pipe_spec", batch_over = "pipe_signatures", batch_as = "pipe_signature", result = "pipe_specs" }, { pipe = "assemble_pipelex_bundle_spec", result = "pipelex_bundle_spec" } ] diff --git a/pipelex/core/bundles/pipelex_bundle_blueprint.py b/pipelex/core/bundles/pipelex_bundle_blueprint.py index cbf104be7..8aa6b5abf 100644 --- a/pipelex/core/bundles/pipelex_bundle_blueprint.py +++ b/pipelex/core/bundles/pipelex_bundle_blueprint.py @@ -10,8 +10,10 @@ from pipelex.core.domains.validation import validate_domain_code from pipelex.core.pipes.validation import is_pipe_code_valid from pipelex.core.pipes.variable_multiplicity import parse_concept_with_multiplicity +from pipelex.core.qualified_ref import QualifiedRef, QualifiedRefError from pipelex.pipe_controllers.batch.pipe_batch_blueprint import PipeBatchBlueprint from pipelex.pipe_controllers.condition.pipe_condition_blueprint import PipeConditionBlueprint +from pipelex.pipe_controllers.condition.special_outcome import SpecialOutcome from pipelex.pipe_controllers.parallel.pipe_parallel_blueprint import PipeParallelBlueprint from pipelex.pipe_controllers.sequence.pipe_sequence_blueprint import PipeSequenceBlueprint from pipelex.pipe_operators.compose.pipe_compose_blueprint import PipeComposeBlueprint @@ -123,18 +125,15 @@ def validate_local_concept_references(self) -> Self: undeclared_refs: list[str] = [] for concept_ref_or_code, context in all_refs: - # Determine if this is a local reference or an external one - if "." in concept_ref_or_code: - # It's a concept ref (domain.ConceptCode) - domain, concept_code = concept_ref_or_code.split(".", 1) - if domain != self.domain: - # External reference - skip validation (will be validated when loading dependencies) - continue - else: - # It's a bare concept code - always local - concept_code = concept_ref_or_code - - # Validate local reference + # Parse the reference using QualifiedRef + ref = QualifiedRef.parse(concept_ref_or_code) + + if ref.is_external_to(self.domain): + # External reference - skip validation (will be validated when loading dependencies) + continue + + # Local reference (bare code or same domain) - validate + concept_code = ref.local_code if concept_code not in declared_concepts and concept_code not in native_codes: undeclared_refs.append(f"'{concept_ref_or_code}' in {context}") @@ -148,6 +147,81 @@ def validate_local_concept_references(self) -> Self: raise ValueError(msg) return self + @model_validator(mode="after") + def validate_local_pipe_references(self) -> Self: + """Validate that domain-qualified pipe references pointing to this bundle's domain exist locally. + + Three categories: + - Bare refs (no dot): no validation here (deferred to package-level resolution) + - Domain-qualified, same domain: must exist in self.pipe + - Domain-qualified, different domain: skip (external, validated at load time) + + Special outcomes ("fail", "continue") are excluded from validation. + """ + declared_pipes: set[str] = set(self.pipe.keys()) if self.pipe else set() + special_outcomes = SpecialOutcome.value_list() + all_pipe_refs = self._collect_pipe_references() + + invalid_refs: list[str] = [] + for pipe_ref_str, context in all_pipe_refs: + # Skip special outcomes + if pipe_ref_str in special_outcomes: + continue + + # Try to parse as a pipe ref + try: + ref = QualifiedRef.parse_pipe_ref(pipe_ref_str) + except QualifiedRefError: + # If it doesn't parse as a valid pipe ref, skip (will be caught elsewhere) + continue + + if not ref.is_qualified: + # Bare ref - no validation at bundle level + continue + + if ref.is_external_to(self.domain): + # External domain - skip + continue + + # Same domain, qualified ref - must exist locally + if ref.local_code not in declared_pipes: + invalid_refs.append(f"'{pipe_ref_str}' in {context}") + + if invalid_refs: + msg = ( + f"The following same-domain pipe references are not declared in domain '{self.domain}' " + f"at '{self.source}': {', '.join(invalid_refs)}. " + f"Declared pipes: {sorted(declared_pipes) if declared_pipes else '(none)'}" + ) + raise ValueError(msg) + return self + + def _collect_pipe_references(self) -> list[tuple[str, str]]: + """Collect all pipe references from controller blueprints. + + Returns: + List of (pipe_ref_string, context_description) tuples + """ + pipe_refs: list[tuple[str, str]] = [] + if not self.pipe: + return pipe_refs + + for pipe_code, pipe_blueprint in self.pipe.items(): + if isinstance(pipe_blueprint, PipeSequenceBlueprint): + for step_index, step in enumerate(pipe_blueprint.steps): + pipe_refs.append((step.pipe, f"pipe.{pipe_code}.steps[{step_index}].pipe")) + elif isinstance(pipe_blueprint, PipeBatchBlueprint): + pipe_refs.append((pipe_blueprint.branch_pipe_code, f"pipe.{pipe_code}.branch_pipe_code")) + elif isinstance(pipe_blueprint, PipeConditionBlueprint): + for outcome_key, outcome_pipe in pipe_blueprint.outcomes.items(): + pipe_refs.append((outcome_pipe, f"pipe.{pipe_code}.outcomes[{outcome_key}]")) + pipe_refs.append((pipe_blueprint.default_outcome, f"pipe.{pipe_code}.default_outcome")) + elif isinstance(pipe_blueprint, PipeParallelBlueprint): + for branch_index, branch in enumerate(pipe_blueprint.branches): + pipe_refs.append((branch.pipe, f"pipe.{pipe_code}.branches[{branch_index}].pipe")) + + return pipe_refs + def _collect_local_concept_references(self) -> list[tuple[str, str]]: local_refs: list[tuple[str, str]] = [] diff --git a/pipelex/core/concepts/concept_factory.py b/pipelex/core/concepts/concept_factory.py index 9a22ceda8..1c9576d8f 100644 --- a/pipelex/core/concepts/concept_factory.py +++ b/pipelex/core/concepts/concept_factory.py @@ -16,6 +16,7 @@ from pipelex.core.concepts.structure_generation.generator import StructureGenerator from pipelex.core.concepts.validation import validate_concept_ref_or_code from pipelex.core.domains.domain import SpecialDomain +from pipelex.core.qualified_ref import QualifiedRef from pipelex.core.stuffs.text_content import TextContent from pipelex.types import StrEnum @@ -178,12 +179,14 @@ def make_domain_and_concept_code_from_concept_ref_or_code( raise ConceptFactoryError(msg) from exc if NativeConceptCode.is_native_concept_ref_or_code(concept_ref_or_code=concept_ref_or_code): - natice_concept_ref = NativeConceptCode.get_validated_native_concept_ref(concept_ref_or_code=concept_ref_or_code) - return DomainAndConceptCode(domain_code=SpecialDomain.NATIVE, concept_code=natice_concept_ref.split(".")[1]) + native_concept_ref = NativeConceptCode.get_validated_native_concept_ref(concept_ref_or_code=concept_ref_or_code) + ref = QualifiedRef.parse(native_concept_ref) + return DomainAndConceptCode(domain_code=SpecialDomain.NATIVE, concept_code=ref.local_code) if "." in concept_ref_or_code: - domain_code, concept_code = concept_ref_or_code.rsplit(".") - return DomainAndConceptCode(domain_code=domain_code, concept_code=concept_code) + ref = QualifiedRef.parse(concept_ref_or_code) + assert ref.domain_path is not None + return DomainAndConceptCode(domain_code=ref.domain_path, concept_code=ref.local_code) elif domain_code: return DomainAndConceptCode(domain_code=domain_code, concept_code=concept_ref_or_code) else: @@ -365,7 +368,8 @@ def _handle_refines( # Get the refined concept's structure class name # For native concepts, the structure class name is "ConceptCode" + "Content" (e.g., TextContent) # For custom concepts, the structure class name is just the concept code (e.g., Customer) - refined_concept_code = current_refine.split(".")[1] + refined_ref = QualifiedRef.parse(current_refine) + refined_concept_code = refined_ref.local_code if NativeConceptCode.is_native_concept_ref_or_code(concept_ref_or_code=current_refine): refined_structure_class_name = refined_concept_code + "Content" else: diff --git a/pipelex/core/concepts/helpers.py b/pipelex/core/concepts/helpers.py index ce7040873..bf17699a2 100644 --- a/pipelex/core/concepts/helpers.py +++ b/pipelex/core/concepts/helpers.py @@ -4,6 +4,7 @@ from pipelex.core.concepts.concept_structure_blueprint import ConceptStructureBlueprint, ConceptStructureBlueprintFieldType from pipelex.core.concepts.validation import is_concept_ref_or_code_valid +from pipelex.core.qualified_ref import QualifiedRef if TYPE_CHECKING: from pipelex.core.concepts.concept_blueprint import ConceptBlueprint @@ -35,10 +36,8 @@ def get_structure_class_name_from_blueprint( raise ValueError(msg) # Extract concept_code from concept_ref_or_code - if "." in concept_ref_or_code: - concept_code = concept_ref_or_code.rsplit(".", maxsplit=1)[-1] - else: - concept_code = concept_ref_or_code + ref = QualifiedRef.parse(concept_ref_or_code) + concept_code = ref.local_code if isinstance(blueprint_or_string_description, str): return concept_code @@ -101,6 +100,5 @@ def extract_concept_code_from_concept_ref_or_code(concept_ref_or_code: str) -> s msg = f"Invalid concept_ref_or_code: '{concept_ref_or_code}' for extracting concept code" raise ValueError(msg) - if "." in concept_ref_or_code: - return concept_ref_or_code.rsplit(".", maxsplit=1)[-1] - return concept_ref_or_code + ref = QualifiedRef.parse(concept_ref_or_code) + return ref.local_code diff --git a/pipelex/core/concepts/native/concept_native.py b/pipelex/core/concepts/native/concept_native.py index f6cbcee27..bba314e77 100644 --- a/pipelex/core/concepts/native/concept_native.py +++ b/pipelex/core/concepts/native/concept_native.py @@ -1,6 +1,7 @@ from pipelex.core.concepts.native.exceptions import NativeConceptDefinitionError from pipelex.core.concepts.validation import is_concept_ref_or_code_valid from pipelex.core.domains.domain import SpecialDomain +from pipelex.core.qualified_ref import QualifiedRef from pipelex.core.stuffs.document_content import DocumentContent from pipelex.core.stuffs.dynamic_content import DynamicContent from pipelex.core.stuffs.html_content import HtmlContent @@ -160,8 +161,9 @@ def is_native_concept_ref_or_code(cls, concept_ref_or_code: str) -> bool: return False if "." in concept_ref_or_code: - domain_code, concept_code = concept_ref_or_code.split(".", 1) - return SpecialDomain.is_native(domain_code=domain_code) and concept_code in cls.values_list() + ref = QualifiedRef.parse(concept_ref_or_code) + assert ref.domain_path is not None + return SpecialDomain.is_native(domain_code=ref.domain_path) and ref.local_code in cls.values_list() return concept_ref_or_code in cls.values_list() @classmethod @@ -179,8 +181,9 @@ def is_valid_native_concept_ref(cls, concept_ref: str) -> bool: """ if "." not in concept_ref: return False - domain_code, concept_code = concept_ref.split(".", 1) - return SpecialDomain.is_native(domain_code=domain_code) and concept_code in cls.values_list() + ref = QualifiedRef.parse(concept_ref) + assert ref.domain_path is not None + return SpecialDomain.is_native(domain_code=ref.domain_path) and ref.local_code in cls.values_list() @classmethod def validate_native_concept_ref_or_code(cls, concept_ref_or_code: str) -> None: diff --git a/pipelex/core/concepts/validation.py b/pipelex/core/concepts/validation.py index 67448ee13..4bb02f9b3 100644 --- a/pipelex/core/concepts/validation.py +++ b/pipelex/core/concepts/validation.py @@ -1,5 +1,5 @@ from pipelex.core.concepts.exceptions import ConceptCodeError, ConceptStringError -from pipelex.core.domains.validation import is_domain_code_valid +from pipelex.core.qualified_ref import QualifiedRef, QualifiedRefError from pipelex.tools.misc.string_utils import is_pascal_case @@ -14,40 +14,38 @@ def validate_concept_code(concept_code: str) -> None: def is_concept_ref_valid(concept_ref: str) -> bool: - if "." not in concept_ref: - return False - - if concept_ref.count(".") > 1: - return False - - domain, concept_code = concept_ref.split(".", 1) + """Check if a concept reference (domain.ConceptCode) is valid. - # Validate domain - if not is_domain_code_valid(code=domain): + Supports hierarchical domains: "legal.contracts.NonCompeteClause" is valid. + """ + try: + ref = QualifiedRef.parse_concept_ref(concept_ref) + except QualifiedRefError: return False - - # Validate concept code - return is_concept_code_valid(concept_code=concept_code) + return ref.is_qualified def validate_concept_ref(concept_ref: str) -> None: if not is_concept_ref_valid(concept_ref=concept_ref): msg = ( f"Concept string '{concept_ref}' is not a valid concept string. It must be in the format 'domain.ConceptCode': " - " - domain: a valid domain code (snake_case), " + " - domain: a valid domain code (snake_case, possibly hierarchical like legal.contracts), " " - ConceptCode: a valid concept code (PascalCase)" ) raise ConceptStringError(msg) def is_concept_ref_or_code_valid(concept_ref_or_code: str) -> bool: - if concept_ref_or_code.count(".") > 1: - return False + """Check if a concept reference or bare code is valid. - if concept_ref_or_code.count(".") == 1: + Supports hierarchical domains: "legal.contracts.NonCompeteClause" is valid. + Bare codes must be PascalCase: "NonCompeteClause" is valid. + """ + if not concept_ref_or_code: + return False + if "." in concept_ref_or_code: return is_concept_ref_valid(concept_ref=concept_ref_or_code) - else: - return is_concept_code_valid(concept_code=concept_ref_or_code) + return is_concept_code_valid(concept_code=concept_ref_or_code) def validate_concept_ref_or_code(concept_ref_or_code: str) -> None: diff --git a/pipelex/core/domains/validation.py b/pipelex/core/domains/validation.py index 9d3c1f00b..ecf62ac33 100644 --- a/pipelex/core/domains/validation.py +++ b/pipelex/core/domains/validation.py @@ -1,12 +1,24 @@ +from typing import Any + from pipelex.core.domains.exceptions import DomainCodeError from pipelex.tools.misc.string_utils import is_snake_case -def is_domain_code_valid(code: str) -> bool: - return is_snake_case(code) +def is_domain_code_valid(code: Any) -> bool: + """Check if a domain code is valid. + + Accepts single-segment (e.g. "legal") and hierarchical dotted paths + (e.g. "legal.contracts", "legal.contracts.shareholder"). + Each segment must be snake_case. + """ + if not isinstance(code, str): + return False + if not code or code.startswith(".") or code.endswith(".") or ".." in code: + return False + return all(is_snake_case(segment) for segment in code.split(".")) def validate_domain_code(code: str) -> None: if not is_domain_code_valid(code=code): - msg = f"Domain code '{code}' is not a valid domain code. It should be in snake_case." + msg = f"Domain code '{code}' is not a valid domain code. It should be in snake_case (segments separated by dots for hierarchical domains)." raise DomainCodeError(msg) diff --git a/pipelex/core/pipes/variable_multiplicity.py b/pipelex/core/pipes/variable_multiplicity.py index 101a9069d..3652fc889 100644 --- a/pipelex/core/pipes/variable_multiplicity.py +++ b/pipelex/core/pipes/variable_multiplicity.py @@ -8,7 +8,7 @@ VariableMultiplicity = bool | int -MUTLIPLICITY_PATTERN = r"^([a-zA-Z_][a-zA-Z0-9_]*(?:\.[a-zA-Z_][a-zA-Z0-9_]*)?)(?:\[(\d*)\])?$" +MUTLIPLICITY_PATTERN = r"^([a-zA-Z_][a-zA-Z0-9_]*(?:\.[a-zA-Z_][a-zA-Z0-9_]*)*)(?:\[(\d*)\])?$" class VariableMultiplicityResolution(BaseModel): @@ -77,8 +77,8 @@ def parse_concept_with_multiplicity(concept_ref_or_code: str) -> MultiplicityPar or if multiplicity is zero or negative (a pipe must produce at least one output) """ # Use strict pattern to validate identifier syntax - # Concept must start with letter/underscore, optional domain prefix, optional brackets - pattern = r"^([a-zA-Z_][a-zA-Z0-9_]*(?:\.[a-zA-Z_][a-zA-Z0-9_]*)?)(?:\[(\d*)\])?$" + # Concept must start with letter/underscore, with zero or more dotted domain segments, optional brackets + pattern = r"^([a-zA-Z_][a-zA-Z0-9_]*(?:\.[a-zA-Z_][a-zA-Z0-9_]*)*)(?:\[(\d*)\])?$" match = re.match(pattern, concept_ref_or_code) if not match: diff --git a/pipelex/core/qualified_ref.py b/pipelex/core/qualified_ref.py new file mode 100644 index 000000000..a50e4b13d --- /dev/null +++ b/pipelex/core/qualified_ref.py @@ -0,0 +1,154 @@ +from pydantic import BaseModel, ConfigDict + +from pipelex.tools.misc.string_utils import is_pascal_case, is_snake_case + + +class QualifiedRefError(ValueError): + """Raised when a qualified reference string is invalid.""" + + +class QualifiedRef(BaseModel): + """A domain-qualified reference to a concept or pipe. + + Concept ref: "legal.contracts.NonCompeteClause" -> domain_path="legal.contracts", local_code="NonCompeteClause" + Pipe ref: "scoring.compute_score" -> domain_path="scoring", local_code="compute_score" + Bare ref: "compute_score" -> domain_path=None, local_code="compute_score" + """ + + model_config = ConfigDict(frozen=True) + + domain_path: str | None = None + local_code: str + + @property + def is_qualified(self) -> bool: + return self.domain_path is not None + + @property + def full_ref(self) -> str: + if self.domain_path: + return f"{self.domain_path}.{self.local_code}" + return self.local_code + + @classmethod + def parse(cls, raw: str) -> "QualifiedRef": + """Split on last dot. No naming-convention check on local_code. + + Args: + raw: The raw reference string to parse + + Returns: + A QualifiedRef with domain_path and local_code + + Raises: + QualifiedRefError: If the raw string is empty, starts/ends with a dot, + or contains consecutive dots + """ + if not raw: + msg = "Qualified reference cannot be empty" + raise QualifiedRefError(msg) + if raw.startswith(".") or raw.endswith("."): + msg = f"Qualified reference '{raw}' must not start or end with a dot" + raise QualifiedRefError(msg) + if ".." in raw: + msg = f"Qualified reference '{raw}' must not contain consecutive dots" + raise QualifiedRefError(msg) + + if "." not in raw: + return cls(domain_path=None, local_code=raw) + + domain_path, local_code = raw.rsplit(".", maxsplit=1) + return cls(domain_path=domain_path, local_code=local_code) + + @classmethod + def parse_concept_ref(cls, raw: str) -> "QualifiedRef": + """Parse a concept ref. Validates domain_path segments are snake_case, local_code is PascalCase. + + Args: + raw: The raw concept reference string to parse + + Returns: + A QualifiedRef with validated domain_path and local_code + + Raises: + QualifiedRefError: If the ref is invalid + """ + ref = cls.parse(raw) + + if not is_pascal_case(ref.local_code): + msg = f"Concept code '{ref.local_code}' in reference '{raw}' must be PascalCase" + raise QualifiedRefError(msg) + + if ref.domain_path is not None: + for segment in ref.domain_path.split("."): + if not is_snake_case(segment): + msg = f"Domain segment '{segment}' in reference '{raw}' must be snake_case" + raise QualifiedRefError(msg) + + return ref + + @classmethod + def parse_pipe_ref(cls, raw: str) -> "QualifiedRef": + """Parse a pipe ref. Validates domain_path segments are snake_case, local_code is snake_case. + + Args: + raw: The raw pipe reference string to parse + + Returns: + A QualifiedRef with validated domain_path and local_code + + Raises: + QualifiedRefError: If the ref is invalid + """ + ref = cls.parse(raw) + + if not is_snake_case(ref.local_code): + msg = f"Pipe code '{ref.local_code}' in reference '{raw}' must be snake_case" + raise QualifiedRefError(msg) + + if ref.domain_path is not None: + for segment in ref.domain_path.split("."): + if not is_snake_case(segment): + msg = f"Domain segment '{segment}' in reference '{raw}' must be snake_case" + raise QualifiedRefError(msg) + + return ref + + @classmethod + def from_domain_and_code(cls, domain_path: str, local_code: str) -> "QualifiedRef": + """Build from already-known parts. + + Args: + domain_path: The domain path (e.g. "legal.contracts") + local_code: The local code (e.g. "NonCompeteClause" or "compute_score") + + Returns: + A QualifiedRef + """ + return cls(domain_path=domain_path, local_code=local_code) + + def is_local_to(self, domain: str) -> bool: + """True if this ref belongs to the given domain (same domain or bare). + + Args: + domain: The domain to check against + + Returns: + True if this ref is local to the given domain + """ + if self.domain_path is None: + return True + return self.domain_path == domain + + def is_external_to(self, domain: str) -> bool: + """True if this ref belongs to a different domain. + + Args: + domain: The domain to check against + + Returns: + True if this ref is qualified and points to a different domain + """ + if self.domain_path is None: + return False + return self.domain_path != domain diff --git a/pipelex/libraries/pipe/pipe_library.py b/pipelex/libraries/pipe/pipe_library.py index 805651306..36f4b33f6 100644 --- a/pipelex/libraries/pipe/pipe_library.py +++ b/pipelex/libraries/pipe/pipe_library.py @@ -7,6 +7,7 @@ from pipelex import pretty_print from pipelex.core.pipes.pipe_abstract import PipeAbstract +from pipelex.core.qualified_ref import QualifiedRef from pipelex.libraries.pipe.exceptions import PipeLibraryError, PipeNotFoundError from pipelex.libraries.pipe.pipe_library_abstract import PipeLibraryAbstract from pipelex.types import Self @@ -53,7 +54,15 @@ def add_pipes(self, pipes: list[PipeAbstract]): @override def get_optional_pipe(self, pipe_code: str) -> PipeAbstract | None: - return self.root.get(pipe_code) + # Direct lookup first (bare code or exact match) + pipe = self.root.get(pipe_code) + if pipe is not None: + return pipe + # If it's a domain-qualified ref (e.g. "scoring.compute_score"), try the local code + if "." in pipe_code: + ref = QualifiedRef.parse(pipe_code) + return self.root.get(ref.local_code) + return None @override def get_required_pipe(self, pipe_code: str) -> PipeAbstract: diff --git a/tests/integration/pipelex/phase1_hierarchical_domains/invalid_fixtures/invalid_double_dot.mthds_invalid b/tests/integration/pipelex/phase1_hierarchical_domains/invalid_fixtures/invalid_double_dot.mthds_invalid new file mode 100644 index 000000000..5b9096ea0 --- /dev/null +++ b/tests/integration/pipelex/phase1_hierarchical_domains/invalid_fixtures/invalid_double_dot.mthds_invalid @@ -0,0 +1,5 @@ +domain = "legal..contracts" +description = "Invalid domain with double dots" + +[concept] +TestConcept = "A test concept" diff --git a/tests/integration/pipelex/phase1_hierarchical_domains/invalid_fixtures/invalid_leading_dot.mthds_invalid b/tests/integration/pipelex/phase1_hierarchical_domains/invalid_fixtures/invalid_leading_dot.mthds_invalid new file mode 100644 index 000000000..505ac0291 --- /dev/null +++ b/tests/integration/pipelex/phase1_hierarchical_domains/invalid_fixtures/invalid_leading_dot.mthds_invalid @@ -0,0 +1,5 @@ +domain = ".legal" +description = "Invalid domain with leading dot" + +[concept] +TestConcept = "A test concept" diff --git a/tests/integration/pipelex/phase1_hierarchical_domains/invalid_fixtures/invalid_same_domain_pipe_ref.mthds_invalid b/tests/integration/pipelex/phase1_hierarchical_domains/invalid_fixtures/invalid_same_domain_pipe_ref.mthds_invalid new file mode 100644 index 000000000..0e302774e --- /dev/null +++ b/tests/integration/pipelex/phase1_hierarchical_domains/invalid_fixtures/invalid_same_domain_pipe_ref.mthds_invalid @@ -0,0 +1,11 @@ +domain = "my_domain" +description = "Invalid: same-domain pipe ref to non-existent pipe" + +[pipe] +[pipe.my_sequence] +type = "PipeSequence" +description = "Sequence with invalid same-domain ref" +output = "Text" +steps = [ + { pipe = "my_domain.nonexistent_pipe", result = "something" }, +] diff --git a/tests/integration/pipelex/phase1_hierarchical_domains/test_hierarchical_domains.py b/tests/integration/pipelex/phase1_hierarchical_domains/test_hierarchical_domains.py new file mode 100644 index 000000000..3d63a3251 --- /dev/null +++ b/tests/integration/pipelex/phase1_hierarchical_domains/test_hierarchical_domains.py @@ -0,0 +1,116 @@ +"""E2E spec tests for Phase 1: Hierarchical Domains + Pipe Namespacing. + +These tests validate actual .mthds files through the full pipeline: +interpret -> blueprint -> factory -> dry run (no inference). +""" + +from pathlib import Path + +import pytest + +from pipelex.core.interpreter.exceptions import PipelexInterpreterError +from pipelex.pipeline.validate_bundle import ValidateBundleError, validate_bundle, validate_bundles_from_directory + +VALID_DIR = Path(__file__).parent / "valid_fixtures" +INVALID_DIR = Path(__file__).parent / "invalid_fixtures" + + +@pytest.mark.asyncio(loop_scope="class") +class TestHierarchicalDomainsAndPipeNamespacing: + """E2E spec tests for hierarchical domains and pipe namespacing.""" + + # ========== POSITIVE TESTS ========== + + async def test_single_segment_domain_baseline(self): + """Single-segment domain should work as before.""" + result = await validate_bundle( + mthds_file_path=VALID_DIR / "hierarchical_domain_single.mthds", + library_dirs=[VALID_DIR], + ) + assert result is not None + assert len(result.blueprints) == 1 + assert result.blueprints[0].domain == "legal" + assert len(result.pipes) > 0 + + async def test_nested_hierarchical_domain(self): + """Nested hierarchical domain 'legal.contracts' with concepts and pipes.""" + result = await validate_bundle( + mthds_file_path=VALID_DIR / "hierarchical_domain_nested.mthds", + library_dirs=[VALID_DIR], + ) + assert result is not None + assert len(result.blueprints) == 1 + assert result.blueprints[0].domain == "legal.contracts" + assert result.blueprints[0].concept is not None + assert "NonCompeteClause" in result.blueprints[0].concept + assert len(result.pipes) > 0 + + async def test_deep_hierarchical_domain(self): + """Deeply nested hierarchical domain 'legal.contracts.shareholder'.""" + result = await validate_bundle( + mthds_file_path=VALID_DIR / "hierarchical_domain_deep.mthds", + library_dirs=[VALID_DIR], + ) + assert result is not None + assert len(result.blueprints) == 1 + assert result.blueprints[0].domain == "legal.contracts.shareholder" + assert len(result.pipes) > 0 + + async def test_cross_domain_pipe_ref_in_sequence(self): + """Cross-domain pipe ref 'scoring.compute_score' in a PipeSequence step.""" + result = await validate_bundle( + mthds_file_path=VALID_DIR / "cross_domain_pipe_refs.mthds", + library_dirs=[VALID_DIR], + ) + assert result is not None + assert len(result.blueprints) == 1 + assert result.blueprints[0].domain == "orchestration" + assert len(result.pipes) > 0 + + async def test_cross_domain_concept_ref_with_hierarchical_domain(self): + """Cross-domain concept ref 'legal.contracts.NonCompeteClause' as input.""" + result = await validate_bundle( + mthds_file_path=VALID_DIR / "cross_domain_concept_refs.mthds", + library_dirs=[VALID_DIR], + ) + assert result is not None + assert len(result.blueprints) == 1 + assert result.blueprints[0].domain == "analysis" + assert len(result.pipes) > 0 + + async def test_multi_bundle_directory_load(self): + """All valid .mthds files from the fixtures directory loaded together.""" + result = await validate_bundles_from_directory(directory=VALID_DIR) + assert result is not None + assert len(result.blueprints) >= 6 + + domain_names = {blueprint.domain for blueprint in result.blueprints} + assert "legal" in domain_names + assert "legal.contracts" in domain_names + assert "legal.contracts.shareholder" in domain_names + assert "scoring" in domain_names + assert "orchestration" in domain_names + assert "analysis" in domain_names + + # ========== NEGATIVE TESTS ========== + + async def test_invalid_double_dot_domain(self): + """Domain 'legal..contracts' should raise a validation error.""" + with pytest.raises((ValidateBundleError, PipelexInterpreterError)): + await validate_bundle( + mthds_file_path=INVALID_DIR / "invalid_double_dot.mthds_invalid", + ) + + async def test_invalid_leading_dot_domain(self): + """Domain '.legal' should raise a validation error.""" + with pytest.raises((ValidateBundleError, PipelexInterpreterError)): + await validate_bundle( + mthds_file_path=INVALID_DIR / "invalid_leading_dot.mthds_invalid", + ) + + async def test_invalid_same_domain_pipe_ref_to_nonexistent(self): + """Same-domain pipe ref to non-existent pipe should raise error.""" + with pytest.raises((ValidateBundleError, PipelexInterpreterError)): + await validate_bundle( + mthds_file_path=INVALID_DIR / "invalid_same_domain_pipe_ref.mthds_invalid", + ) diff --git a/tests/integration/pipelex/phase1_hierarchical_domains/valid_fixtures/cross_domain_concept_refs.mthds b/tests/integration/pipelex/phase1_hierarchical_domains/valid_fixtures/cross_domain_concept_refs.mthds new file mode 100644 index 000000000..f9421de39 --- /dev/null +++ b/tests/integration/pipelex/phase1_hierarchical_domains/valid_fixtures/cross_domain_concept_refs.mthds @@ -0,0 +1,11 @@ +domain = "analysis" +description = "Analysis domain using cross-domain concept references" + +[pipe] +[pipe.analyze_clause] +type = "PipeLLM" +description = "Analyze a non-compete clause from the legal.contracts domain" +inputs = { clause = "legal.contracts.NonCompeteClause" } +output = "Text" +model = "$quick-reasoning" +prompt = "Analyze @clause" diff --git a/tests/integration/pipelex/phase1_hierarchical_domains/valid_fixtures/cross_domain_pipe_refs.mthds b/tests/integration/pipelex/phase1_hierarchical_domains/valid_fixtures/cross_domain_pipe_refs.mthds new file mode 100644 index 000000000..238ada3e0 --- /dev/null +++ b/tests/integration/pipelex/phase1_hierarchical_domains/valid_fixtures/cross_domain_pipe_refs.mthds @@ -0,0 +1,12 @@ +domain = "orchestration" +description = "Orchestration domain using cross-domain pipe references" + +[pipe] +[pipe.orchestrate] +type = "PipeSequence" +description = "Orchestrate scoring via cross-domain pipe ref" +inputs = { data = "Text" } +output = "scoring.WeightedScore" +steps = [ + { pipe = "scoring.compute_score", result = "score" }, +] diff --git a/tests/integration/pipelex/phase1_hierarchical_domains/valid_fixtures/hierarchical_domain_deep.mthds b/tests/integration/pipelex/phase1_hierarchical_domains/valid_fixtures/hierarchical_domain_deep.mthds new file mode 100644 index 000000000..4a22f96d4 --- /dev/null +++ b/tests/integration/pipelex/phase1_hierarchical_domains/valid_fixtures/hierarchical_domain_deep.mthds @@ -0,0 +1,14 @@ +domain = "legal.contracts.shareholder" +description = "Deeply nested hierarchical domain for shareholder contracts" + +[concept] +ShareholderAgreement = "A shareholder agreement document" + +[pipe] +[pipe.analyze_agreement] +type = "PipeLLM" +description = "Analyze a shareholder agreement" +inputs = { agreement = "ShareholderAgreement" } +output = "Text" +model = "$quick-reasoning" +prompt = "Analyze @agreement" diff --git a/tests/integration/pipelex/phase1_hierarchical_domains/valid_fixtures/hierarchical_domain_nested.mthds b/tests/integration/pipelex/phase1_hierarchical_domains/valid_fixtures/hierarchical_domain_nested.mthds new file mode 100644 index 000000000..63e7fae3d --- /dev/null +++ b/tests/integration/pipelex/phase1_hierarchical_domains/valid_fixtures/hierarchical_domain_nested.mthds @@ -0,0 +1,15 @@ +domain = "legal.contracts" +description = "Nested hierarchical domain for legal contracts" + +[concept] +NonCompeteClause = "A non-compete clause in a contract" +ContractSummary = "A summary of a contract" + +[pipe] +[pipe.summarize_contract] +type = "PipeLLM" +description = "Summarize a contract" +inputs = { clause = "NonCompeteClause" } +output = "ContractSummary" +model = "$quick-reasoning" +prompt = "Summarize @clause" diff --git a/tests/integration/pipelex/phase1_hierarchical_domains/valid_fixtures/hierarchical_domain_single.mthds b/tests/integration/pipelex/phase1_hierarchical_domains/valid_fixtures/hierarchical_domain_single.mthds new file mode 100644 index 000000000..143ce5c8b --- /dev/null +++ b/tests/integration/pipelex/phase1_hierarchical_domains/valid_fixtures/hierarchical_domain_single.mthds @@ -0,0 +1,14 @@ +domain = "legal" +description = "Single-segment domain baseline" + +[concept] +ContractClause = "A clause in a legal contract" + +[pipe] +[pipe.extract_clause] +type = "PipeLLM" +description = "Extract a clause from a contract" +inputs = { contract = "Text" } +output = "ContractClause" +model = "$quick-reasoning" +prompt = "Extract the clause from @contract" diff --git a/tests/integration/pipelex/phase1_hierarchical_domains/valid_fixtures/scoring.mthds b/tests/integration/pipelex/phase1_hierarchical_domains/valid_fixtures/scoring.mthds new file mode 100644 index 000000000..a5f11a99b --- /dev/null +++ b/tests/integration/pipelex/phase1_hierarchical_domains/valid_fixtures/scoring.mthds @@ -0,0 +1,14 @@ +domain = "scoring" +description = "Scoring domain for cross-domain dependency targets" + +[concept] +WeightedScore = "A weighted score result" + +[pipe] +[pipe.compute_score] +type = "PipeLLM" +description = "Compute a weighted score" +inputs = { data = "Text" } +output = "WeightedScore" +model = "$quick-reasoning" +prompt = "Compute score from @data" diff --git a/tests/unit/pipelex/core/bundles/test_pipelex_bundle_blueprint_concept_validation.py b/tests/unit/pipelex/core/bundles/test_pipelex_bundle_blueprint_concept_validation.py index b41eb3cbd..ff7d847a4 100644 --- a/tests/unit/pipelex/core/bundles/test_pipelex_bundle_blueprint_concept_validation.py +++ b/tests/unit/pipelex/core/bundles/test_pipelex_bundle_blueprint_concept_validation.py @@ -225,6 +225,62 @@ def test_valid_item_concept_ref_in_structure(self): ) assert bundle.concept is not None + # ========== HIERARCHICAL DOMAIN CASES ========== + + def test_valid_hierarchical_domain_concept_ref_output(self): + """Hierarchical domain concept ref for same domain should be valid.""" + bundle = PipelexBundleBlueprint( + domain="legal.contracts", + description="Test bundle", + concept={"NonCompeteClause": "A non-compete clause concept"}, + pipe={ + "my_pipe": PipeLLMBlueprint( + type="PipeLLM", + description="Test pipe", + output="legal.contracts.NonCompeteClause", + prompt="Generate something", + ), + }, + ) + assert bundle.concept is not None + + def test_valid_hierarchical_domain_external_concept_ref(self): + """External concept ref from a different hierarchical domain should be skipped.""" + bundle = PipelexBundleBlueprint( + domain="legal.contracts", + description="Test bundle", + pipe={ + "my_pipe": PipeLLMBlueprint( + type="PipeLLM", + description="Test pipe", + inputs={"score": "scoring.WeightedScore"}, + output="Text", + prompt="Process @score", + ), + }, + ) + assert bundle.pipe is not None + + def test_invalid_hierarchical_domain_undeclared_same_domain(self): + """Hierarchical same-domain concept ref that is not declared should raise error.""" + with pytest.raises(ValidationError) as exc_info: + PipelexBundleBlueprint( + domain="legal.contracts", + description="Test bundle", + pipe={ + "my_pipe": PipeLLMBlueprint( + type="PipeLLM", + description="Test pipe", + output="legal.contracts.Missing", + prompt="Generate something", + ), + }, + ) + + error_message = str(exc_info.value) + assert "Missing" in error_message + assert "not declared in domain" in error_message + # ========== INVALID CASES ========== def test_invalid_undeclared_local_concept_in_pipe_output(self): diff --git a/tests/unit/pipelex/core/bundles/test_pipelex_bundle_blueprint_pipe_validation.py b/tests/unit/pipelex/core/bundles/test_pipelex_bundle_blueprint_pipe_validation.py new file mode 100644 index 000000000..65a7264b7 --- /dev/null +++ b/tests/unit/pipelex/core/bundles/test_pipelex_bundle_blueprint_pipe_validation.py @@ -0,0 +1,194 @@ +import pytest +from pydantic import ValidationError + +from pipelex.core.bundles.pipelex_bundle_blueprint import PipelexBundleBlueprint +from pipelex.pipe_controllers.batch.pipe_batch_blueprint import PipeBatchBlueprint +from pipelex.pipe_controllers.condition.pipe_condition_blueprint import PipeConditionBlueprint +from pipelex.pipe_controllers.sequence.pipe_sequence_blueprint import PipeSequenceBlueprint +from pipelex.pipe_controllers.sub_pipe_blueprint import SubPipeBlueprint +from pipelex.pipe_operators.llm.pipe_llm_blueprint import PipeLLMBlueprint + + +class TestPipelexBundleBlueprintPipeValidation: + """Test validation of pipe references in PipelexBundleBlueprint.""" + + # ========== VALID CASES ========== + + def test_valid_bare_step_refs_to_local_pipes(self): + """Bare step refs (no domain prefix) should pass without validation at bundle level.""" + bundle = PipelexBundleBlueprint( + domain="my_domain", + description="Test bundle", + concept={"Result": "A result concept"}, + pipe={ + "step1": PipeLLMBlueprint( + type="PipeLLM", + description="Step 1", + output="Text", + prompt="Hello", + ), + "step2": PipeLLMBlueprint( + type="PipeLLM", + description="Step 2", + output="Result", + prompt="Process", + ), + "my_sequence": PipeSequenceBlueprint( + type="PipeSequence", + description="Main sequence", + output="Result", + steps=[ + SubPipeBlueprint(pipe="step1", result="intermediate"), + SubPipeBlueprint(pipe="step2", result="final"), + ], + ), + }, + ) + assert bundle.pipe is not None + + def test_valid_external_pipe_ref_in_sequence(self): + """External domain-qualified pipe ref should be skipped (not validated locally).""" + bundle = PipelexBundleBlueprint( + domain="orchestration", + description="Test bundle", + pipe={ + "my_sequence": PipeSequenceBlueprint( + type="PipeSequence", + description="Orchestration sequence", + output="Text", + steps=[ + SubPipeBlueprint(pipe="scoring.compute_score", result="score"), + ], + ), + }, + ) + assert bundle.pipe is not None + + def test_valid_special_outcomes_not_treated_as_pipe_refs(self): + """Special outcomes like 'fail' and 'continue' should not be validated as pipe refs.""" + bundle = PipelexBundleBlueprint( + domain="my_domain", + description="Test bundle", + concept={"Result": "A result concept"}, + pipe={ + "good_pipe": PipeLLMBlueprint( + type="PipeLLM", + description="Good pipe", + output="Result", + prompt="Do something", + ), + "my_condition": PipeConditionBlueprint( + type="PipeCondition", + description="Condition check", + output="Result", + expression="True", + outcomes={"True": "good_pipe"}, + default_outcome="fail", + ), + }, + ) + assert bundle.pipe is not None + + def test_valid_external_batch_pipe_ref(self): + """External domain-qualified branch_pipe_code should be skipped.""" + bundle = PipelexBundleBlueprint( + domain="orchestration", + description="Test bundle", + pipe={ + "my_batch": PipeBatchBlueprint( + type="PipeBatch", + description="Batch process", + output="Text[]", + inputs={"items": "Text[]"}, + branch_pipe_code="scoring.process_item", + input_list_name="items", + input_item_name="item", + ), + }, + ) + assert bundle.pipe is not None + + def test_valid_bare_ref_to_nonexistent_pipe(self): + """Bare refs to pipes not declared locally should pass (deferred to package-level).""" + bundle = PipelexBundleBlueprint( + domain="my_domain", + description="Test bundle", + pipe={ + "my_sequence": PipeSequenceBlueprint( + type="PipeSequence", + description="Main sequence", + output="Text", + steps=[ + SubPipeBlueprint(pipe="nonexistent_step", result="something"), + ], + ), + }, + ) + assert bundle.pipe is not None + + # ========== INVALID CASES ========== + + def test_invalid_same_domain_pipe_ref_to_nonexistent_pipe(self): + """Same-domain qualified pipe ref to a non-existent pipe should raise error.""" + with pytest.raises(ValidationError) as exc_info: + PipelexBundleBlueprint( + domain="my_domain", + description="Test bundle", + pipe={ + "my_sequence": PipeSequenceBlueprint( + type="PipeSequence", + description="Main sequence", + output="Text", + steps=[ + SubPipeBlueprint(pipe="my_domain.nonexistent_pipe", result="something"), + ], + ), + }, + ) + + error_message = str(exc_info.value) + assert "my_domain.nonexistent_pipe" in error_message + assert "not declared in domain" in error_message + + def test_invalid_same_domain_batch_pipe_ref(self): + """Same-domain qualified branch_pipe_code to non-existent pipe should raise error.""" + with pytest.raises(ValidationError) as exc_info: + PipelexBundleBlueprint( + domain="my_domain", + description="Test bundle", + pipe={ + "my_batch": PipeBatchBlueprint( + type="PipeBatch", + description="Batch process", + output="Text[]", + inputs={"items": "Text[]"}, + branch_pipe_code="my_domain.nonexistent_branch", + input_list_name="items", + input_item_name="item", + ), + }, + ) + + error_message = str(exc_info.value) + assert "my_domain.nonexistent_branch" in error_message + + def test_invalid_same_domain_condition_outcome_ref(self): + """Same-domain qualified outcome pipe ref to non-existent pipe should raise error.""" + with pytest.raises(ValidationError) as exc_info: + PipelexBundleBlueprint( + domain="my_domain", + description="Test bundle", + pipe={ + "my_condition": PipeConditionBlueprint( + type="PipeCondition", + description="Condition check", + output="Text", + expression="True", + outcomes={"True": "my_domain.nonexistent_handler"}, + default_outcome="fail", + ), + }, + ) + + error_message = str(exc_info.value) + assert "my_domain.nonexistent_handler" in error_message diff --git a/tests/unit/pipelex/core/concepts/helpers/test_get_structure_class_name_from_blueprint.py b/tests/unit/pipelex/core/concepts/helpers/test_get_structure_class_name_from_blueprint.py index 0355063da..04b1e09f0 100644 --- a/tests/unit/pipelex/core/concepts/helpers/test_get_structure_class_name_from_blueprint.py +++ b/tests/unit/pipelex/core/concepts/helpers/test_get_structure_class_name_from_blueprint.py @@ -91,13 +91,13 @@ def test_invalid_concept_ref_or_code_raises_error(self): concept_ref_or_code="invalid_lowercase_code", ) - def test_invalid_nested_domain_raises_error(self): - """Nested domain format (more than one dot) raises ValueError.""" - with pytest.raises(ValueError, match="Invalid concept_ref_or_code"): - get_structure_class_name_from_blueprint( - blueprint_or_string_description="A description", - concept_ref_or_code="domain.subdomain.ConceptName", - ) + def test_hierarchical_domain_extracts_concept_code(self): + """Hierarchical domain format (multiple dots) extracts the concept code correctly.""" + result = get_structure_class_name_from_blueprint( + blueprint_or_string_description="A description", + concept_ref_or_code="domain.subdomain.ConceptName", + ) + assert result == "ConceptName" def test_empty_string_raises_error(self): """Empty string raises ValueError.""" diff --git a/tests/unit/pipelex/core/concepts/test_concept.py b/tests/unit/pipelex/core/concepts/test_concept.py index cd1699b87..3471e8825 100644 --- a/tests/unit/pipelex/core/concepts/test_concept.py +++ b/tests/unit/pipelex/core/concepts/test_concept.py @@ -205,12 +205,9 @@ def test_validate_concept_ref(self): with pytest.raises(ConceptStringError): validate_concept_ref(f"snake_case_domaiN.{valid_concept_code}") - # Multiple dots - with pytest.raises(ConceptStringError): - validate_concept_ref(f"domain.sub.{valid_concept_code}") - - with pytest.raises(ConceptStringError): - validate_concept_ref(f"a.b.c.{valid_concept_code}") + # Hierarchical domains (multiple dots) - now valid + validate_concept_ref(f"domain.sub.{valid_concept_code}") + validate_concept_ref(f"a.b.c.{valid_concept_code}") # Invalid domain (not snake_case) with pytest.raises(ConceptStringError): diff --git a/tests/unit/pipelex/core/concepts/test_validation.py b/tests/unit/pipelex/core/concepts/test_validation.py index c631746d3..ae0ebb669 100644 --- a/tests/unit/pipelex/core/concepts/test_validation.py +++ b/tests/unit/pipelex/core/concepts/test_validation.py @@ -39,6 +39,11 @@ def test_is_concept_code_valid(self, concept_code: str, expected: bool): ("crm.Customer", True), ("my_app.Entity", True), ("domain.A", True), + # Hierarchical domains + ("legal.contracts.NonCompeteClause", True), + ("legal.contracts.shareholder.Agreement", True), + ("a.b.c.D", True), + # Invalid ("native.text", False), ("NATIVE.Text", False), ("my-app.Entity", False), @@ -63,12 +68,13 @@ def test_is_concept_ref_valid(self, concept_ref: str, expected: bool): ("myapp.BaseEntity", True), ("crm.Customer", True), ("my_app.Entity", True), + # Valid - hierarchical domain refs (now supported) + ("org.dept.team.Entity", True), + ("a.b.c.D", True), + ("legal.contracts.NonCompeteClause", True), # Invalid - lowercase bare code ("somecustomconcept", False), ("text", False), - # Invalid - deeply nested domain - ("org.dept.team.Entity", False), - ("a.b.c.D", False), # Invalid - hyphenated domain ("my-app.Entity", False), # Invalid - empty string diff --git a/tests/unit/pipelex/core/domains/test_domain_validation.py b/tests/unit/pipelex/core/domains/test_domain_validation.py new file mode 100644 index 000000000..79c022937 --- /dev/null +++ b/tests/unit/pipelex/core/domains/test_domain_validation.py @@ -0,0 +1,39 @@ +import pytest + +from pipelex.core.domains.validation import is_domain_code_valid + + +class TestDomainValidation: + """Test domain code validation including hierarchical dotted paths.""" + + @pytest.mark.parametrize( + ("code", "expected"), + [ + # Single-segment domains + ("legal", True), + ("my_app", True), + ("native", True), + ("a", True), + # Hierarchical domains + ("legal.contracts", True), + ("legal.contracts.shareholder", True), + ("a.b.c", True), + ("my_app.sub_domain", True), + # Invalid + ("Legal", False), + ("legal.", False), + (".legal", False), + ("legal..contracts", False), + ("legal-contracts", False), + ("", False), + ("123abc", False), + ("UPPER", False), + ("legal.Contracts", False), + ("legal.contracts.", False), + (".legal.contracts", False), + ("legal..contracts.shareholder", False), + ], + ) + def test_is_domain_code_valid(self, code: str, expected: bool): + """Test domain code validation accepts hierarchical dotted paths.""" + assert is_domain_code_valid(code=code) == expected diff --git a/tests/unit/pipelex/core/pipes/test_parse_concept_with_multiplicity.py b/tests/unit/pipelex/core/pipes/test_parse_concept_with_multiplicity.py index e454dcd5b..21878ea3d 100644 --- a/tests/unit/pipelex/core/pipes/test_parse_concept_with_multiplicity.py +++ b/tests/unit/pipelex/core/pipes/test_parse_concept_with_multiplicity.py @@ -90,3 +90,29 @@ def test_invalid_negative_multiplicity(self): with pytest.raises(ValueError, match="Invalid concept specification syntax"): parse_concept_with_multiplicity("domain.Concept[-5]") + + # ========== Hierarchical domain tests ========== + + def test_valid_hierarchical_domain_concept(self): + """Test parsing concept with hierarchical domain (multiple dot segments).""" + result = parse_concept_with_multiplicity("legal.contracts.NonCompeteClause") + assert result.concept_ref_or_code == "legal.contracts.NonCompeteClause" + assert result.multiplicity is None + + def test_valid_hierarchical_domain_concept_with_variable_list(self): + """Test parsing hierarchical domain concept with empty brackets [].""" + result = parse_concept_with_multiplicity("legal.contracts.NonCompeteClause[]") + assert result.concept_ref_or_code == "legal.contracts.NonCompeteClause" + assert result.multiplicity is True + + def test_valid_hierarchical_domain_concept_with_fixed_count(self): + """Test parsing hierarchical domain concept with fixed count [N].""" + result = parse_concept_with_multiplicity("legal.contracts.NonCompeteClause[5]") + assert result.concept_ref_or_code == "legal.contracts.NonCompeteClause" + assert result.multiplicity == 5 + + def test_valid_deep_hierarchical_domain(self): + """Test parsing concept with deeply nested domain.""" + result = parse_concept_with_multiplicity("a.b.c.d.Entity[]") + assert result.concept_ref_or_code == "a.b.c.d.Entity" + assert result.multiplicity is True diff --git a/tests/unit/pipelex/core/test_data/domain/simple_domains.py b/tests/unit/pipelex/core/test_data/domain/simple_domains.py index 4a7bd5c0a..7d28758e4 100644 --- a/tests/unit/pipelex/core/test_data/domain/simple_domains.py +++ b/tests/unit/pipelex/core/test_data/domain/simple_domains.py @@ -24,8 +24,32 @@ ), ) +HIERARCHICAL_DOMAIN = ( + "hierarchical_domain", + """domain = "legal.contracts" +description = "A hierarchical domain for legal contracts" +""", + PipelexBundleBlueprint( + domain="legal.contracts", + description="A hierarchical domain for legal contracts", + ), +) + +DEEP_HIERARCHICAL_DOMAIN = ( + "deep_hierarchical_domain", + """domain = "legal.contracts.shareholder" +description = "A deeply nested hierarchical domain" +""", + PipelexBundleBlueprint( + domain="legal.contracts.shareholder", + description="A deeply nested hierarchical domain", + ), +) + # Export all domain test cases DOMAIN_TEST_CASES = [ SIMPLE_DOMAIN, DOMAIN_WITH_SYSTEM_PROMPTS, + HIERARCHICAL_DOMAIN, + DEEP_HIERARCHICAL_DOMAIN, ] diff --git a/tests/unit/pipelex/core/test_data/errors/invalid_mthds.py b/tests/unit/pipelex/core/test_data/errors/invalid_mthds.py index ea5f67d10..841962f75 100644 --- a/tests/unit/pipelex/core/test_data/errors/invalid_mthds.py +++ b/tests/unit/pipelex/core/test_data/errors/invalid_mthds.py @@ -120,7 +120,7 @@ [concept] TestConcept = "A test concept" """, - TypeError, + PipelexInterpreterError, ) WRONG_TYPE_FOR_DEFINITION = ( @@ -198,6 +198,28 @@ MthdsDecodeError, ) +DOUBLE_DOT_DOMAIN = ( + "double_dot_domain", + """domain = "legal..contracts" +description = "Domain with double dots" + +[concept] +TestConcept = "A test concept" +""", + PipelexInterpreterError, +) + +LEADING_DOT_DOMAIN = ( + "leading_dot_domain", + """domain = ".legal" +description = "Domain with leading dot" + +[concept] +TestConcept = "A test concept" +""", + PipelexInterpreterError, +) + # Export all error test cases ERROR_TEST_CASES: list[tuple[str, str, type[Exception] | tuple[type[Exception], ...]]] = [ # MTHDS Syntax Errors @@ -220,4 +242,7 @@ WRONG_TYPE_FOR_CONCEPT_SECTION, WRONG_TYPE_FOR_PIPE_SECTION, INVALID_NESTED_SECTION, + # Hierarchical Domain Errors + DOUBLE_DOT_DOMAIN, + LEADING_DOT_DOMAIN, ] diff --git a/tests/unit/pipelex/core/test_data/pipes/controllers/sequence/pipe_sequence.py b/tests/unit/pipelex/core/test_data/pipes/controllers/sequence/pipe_sequence.py index c56ff265b..5f763b1a6 100644 --- a/tests/unit/pipelex/core/test_data/pipes/controllers/sequence/pipe_sequence.py +++ b/tests/unit/pipelex/core/test_data/pipes/controllers/sequence/pipe_sequence.py @@ -37,7 +37,39 @@ ), ) +PIPE_SEQUENCE_WITH_CROSS_DOMAIN_REF = ( + "pipe_sequence_with_cross_domain_ref", + """domain = "orchestration" +description = "Domain with cross-domain pipe ref in sequence" + +[pipe.orchestrate] +type = "PipeSequence" +description = "Orchestrate with cross-domain pipe" +output = "Text" +steps = [ + { pipe = "scoring.compute_score", result = "score" }, + { pipe = "format_result", result = "final" }, +] +""", + PipelexBundleBlueprint( + domain="orchestration", + description="Domain with cross-domain pipe ref in sequence", + pipe={ + "orchestrate": PipeSequenceBlueprint( + type="PipeSequence", + description="Orchestrate with cross-domain pipe", + output="Text", + steps=[ + SubPipeBlueprint(pipe="scoring.compute_score", result="score"), + SubPipeBlueprint(pipe="format_result", result="final"), + ], + ), + }, + ), +) + # Export all PipeSequence test cases PIPE_SEQUENCE_TEST_CASES = [ PIPE_SEQUENCE, + PIPE_SEQUENCE_WITH_CROSS_DOMAIN_REF, ] diff --git a/tests/unit/pipelex/core/test_qualified_ref.py b/tests/unit/pipelex/core/test_qualified_ref.py new file mode 100644 index 000000000..42f0e7728 --- /dev/null +++ b/tests/unit/pipelex/core/test_qualified_ref.py @@ -0,0 +1,174 @@ +import pytest +from pydantic import ValidationError + +from pipelex.core.qualified_ref import QualifiedRef, QualifiedRefError + + +class TestQualifiedRef: + """Test centralized reference parsing via QualifiedRef.""" + + # ========== parse() ========== + + @pytest.mark.parametrize( + ("raw", "expected_domain", "expected_code"), + [ + ("Text", None, "Text"), + ("compute_score", None, "compute_score"), + ("native.Text", "native", "Text"), + ("scoring.compute_score", "scoring", "compute_score"), + ("legal.contracts.NonCompeteClause", "legal.contracts", "NonCompeteClause"), + ("a.b.c.D", "a.b.c", "D"), + ], + ) + def test_parse_valid(self, raw: str, expected_domain: str | None, expected_code: str): + """Test parse splits correctly on last dot.""" + ref = QualifiedRef.parse(raw) + assert ref.domain_path == expected_domain + assert ref.local_code == expected_code + + @pytest.mark.parametrize( + "raw", + [ + "", + ".extract", + "domain.", + "legal..contracts.X", + "..foo", + "foo..", + ], + ) + def test_parse_invalid(self, raw: str): + """Test parse raises on invalid input.""" + with pytest.raises(QualifiedRefError): + QualifiedRef.parse(raw) + + # ========== parse_concept_ref() ========== + + @pytest.mark.parametrize( + ("raw", "expected_domain", "expected_code"), + [ + ("native.Text", "native", "Text"), + ("legal.contracts.NonCompeteClause", "legal.contracts", "NonCompeteClause"), + ("legal.contracts.shareholder.Agreement", "legal.contracts.shareholder", "Agreement"), + ("myapp.BaseEntity", "myapp", "BaseEntity"), + ("a.b.c.D", "a.b.c", "D"), + ], + ) + def test_parse_concept_ref_valid(self, raw: str, expected_domain: str | None, expected_code: str): + """Test parse_concept_ref accepts valid concept references.""" + ref = QualifiedRef.parse_concept_ref(raw) + assert ref.domain_path == expected_domain + assert ref.local_code == expected_code + + @pytest.mark.parametrize( + "raw", + [ + "", + "legal..contracts.X", + ".Text", + "native.text", + "NATIVE.Text", + "my-app.Entity", + ], + ) + def test_parse_concept_ref_invalid(self, raw: str): + """Test parse_concept_ref raises on invalid input.""" + with pytest.raises(QualifiedRefError): + QualifiedRef.parse_concept_ref(raw) + + # ========== parse_pipe_ref() ========== + + @pytest.mark.parametrize( + ("raw", "expected_domain", "expected_code"), + [ + ("scoring.compute_score", "scoring", "compute_score"), + ("legal.contracts.extract_clause", "legal.contracts", "extract_clause"), + ("a.b.c.do_thing", "a.b.c", "do_thing"), + ], + ) + def test_parse_pipe_ref_valid(self, raw: str, expected_domain: str | None, expected_code: str): + """Test parse_pipe_ref accepts valid pipe references.""" + ref = QualifiedRef.parse_pipe_ref(raw) + assert ref.domain_path == expected_domain + assert ref.local_code == expected_code + + @pytest.mark.parametrize( + "raw", + [ + "", + ".extract", + "legal..contracts.x", + "scoring.ComputeScore", + "MY_APP.extract", + ], + ) + def test_parse_pipe_ref_invalid(self, raw: str): + """Test parse_pipe_ref raises on invalid input.""" + with pytest.raises(QualifiedRefError): + QualifiedRef.parse_pipe_ref(raw) + + # ========== full_ref ========== + + def test_full_ref_bare(self): + """Test full_ref for bare references.""" + ref = QualifiedRef(domain_path=None, local_code="Text") + assert ref.full_ref == "Text" + + def test_full_ref_qualified(self): + """Test full_ref for domain-qualified references.""" + ref = QualifiedRef(domain_path="legal.contracts", local_code="NonCompeteClause") + assert ref.full_ref == "legal.contracts.NonCompeteClause" + + # ========== is_qualified ========== + + def test_is_qualified_true(self): + ref = QualifiedRef(domain_path="scoring", local_code="compute_score") + assert ref.is_qualified is True + + def test_is_qualified_false(self): + ref = QualifiedRef(domain_path=None, local_code="compute_score") + assert ref.is_qualified is False + + # ========== from_domain_and_code() ========== + + def test_from_domain_and_code(self): + ref = QualifiedRef.from_domain_and_code(domain_path="legal.contracts", local_code="NonCompeteClause") + assert ref.domain_path == "legal.contracts" + assert ref.local_code == "NonCompeteClause" + assert ref.full_ref == "legal.contracts.NonCompeteClause" + + # ========== is_local_to() / is_external_to() ========== + + def test_is_local_to_same_domain(self): + ref = QualifiedRef(domain_path="scoring", local_code="compute_score") + assert ref.is_local_to("scoring") is True + + def test_is_local_to_bare_ref(self): + """Bare refs are always local.""" + ref = QualifiedRef(domain_path=None, local_code="compute_score") + assert ref.is_local_to("scoring") is True + + def test_is_local_to_different_domain(self): + ref = QualifiedRef(domain_path="scoring", local_code="compute_score") + assert ref.is_local_to("orchestration") is False + + def test_is_external_to_different_domain(self): + ref = QualifiedRef(domain_path="scoring", local_code="compute_score") + assert ref.is_external_to("orchestration") is True + + def test_is_external_to_same_domain(self): + ref = QualifiedRef(domain_path="scoring", local_code="compute_score") + assert ref.is_external_to("scoring") is False + + def test_is_external_to_bare_ref(self): + """Bare refs are never external.""" + ref = QualifiedRef(domain_path=None, local_code="compute_score") + assert ref.is_external_to("scoring") is False + + # ========== Frozen model ========== + + def test_frozen_model(self): + """Test that QualifiedRef instances are immutable.""" + ref = QualifiedRef(domain_path="scoring", local_code="compute_score") + with pytest.raises(ValidationError, match="frozen"): + ref.local_code = "other" # type: ignore[misc]