From c66f1ec1cf029a0194a3fafa4a40a07efa5a674f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=C2=A0St=C4=99ch=C5=82y?= Date: Thu, 13 Jun 2024 12:24:08 -0400 Subject: [PATCH] feat: topology verification MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: add topology verification * fix: add missing copyright * fix: fix issues with topology verification Co-authored-by: Konrad Jałowiecki * test: update validation tests * style: fix minor issues --------- Co-authored-by: Konrad Jałowiecki --- .gitignore | 1 + docs/library/userguide.md | 24 +++ src/qref/_schema_v1.py | 33 +++- src/qref/verification.py | 154 ++++++++++++++++++ tests/conftest.py | 2 +- .../qref/data/invalid_pydantic_programs.yaml | 27 +++ .../qref/data/invalid_topology_programs.yaml | 134 +++++++++++++++ ...amples.yaml => invalid_yaml_programs.yaml} | 1 + tests/qref/data/valid_programs/example_2.yaml | 3 - tests/qref/data/valid_programs/example_4.yaml | 112 +++++++++++++ tests/qref/experimental/test_rendering.py | 2 - tests/qref/test_schema_validation.py | 13 +- tests/qref/test_topology_verification.py | 51 ++++++ 13 files changed, 545 insertions(+), 12 deletions(-) create mode 100644 src/qref/verification.py create mode 100644 tests/qref/data/invalid_pydantic_programs.yaml create mode 100644 tests/qref/data/invalid_topology_programs.yaml rename tests/qref/data/{invalid_program_examples.yaml => invalid_yaml_programs.yaml} (99%) create mode 100644 tests/qref/data/valid_programs/example_4.yaml create mode 100644 tests/qref/test_topology_verification.py diff --git a/.gitignore b/.gitignore index b1cb160..23b4763 100644 --- a/.gitignore +++ b/.gitignore @@ -159,3 +159,4 @@ cython_debug/ # option (not recommended) you can uncomment the following to ignore the entire idea folder. #.idea/ +.vscode/ diff --git a/docs/library/userguide.md b/docs/library/userguide.md index 7e9f1ea..215b89f 100644 --- a/docs/library/userguide.md +++ b/docs/library/userguide.md @@ -52,6 +52,30 @@ data = load_some_program() program = SchemaV1.model_validate(data) ``` +### Topology validation + +There can be cases where a program is correct from the perspective of Pydantic validation, but has incorrect topology. This includes cases such as: + +- Disconnected ports +- Ports with multiple connections +- Cycles in the graph + +In order to validate whether the topology of the program is correct you can use `verify_topology` method. Here's a short snippet showing how one can verify their program and print out the problems (if any). + +```python +from qref.verification import verify_topology + +program = load_some_program() + +verification_output = verify_topology(program) + +if not verification_output: + print("Program topology is incorrect, due to the following issues:") + for problem in verification_output.problems: + print(problem) + +``` + ### Rendering QREF files using `qref-render` (experimental) !!! Warning diff --git a/src/qref/_schema_v1.py b/src/qref/_schema_v1.py index f0a1b65..64866c7 100644 --- a/src/qref/_schema_v1.py +++ b/src/qref/_schema_v1.py @@ -18,7 +18,14 @@ from typing import Annotated, Any, Literal, Optional, Union -from pydantic import AfterValidator, BaseModel, ConfigDict, Field, StringConstraints +from pydantic import ( + AfterValidator, + BaseModel, + ConfigDict, + Field, + StringConstraints, + field_validator, +) from pydantic.json_schema import GenerateJsonSchema NAME_PATTERN = "[A-Za-z_][A-Za-z0-9_]*" @@ -96,6 +103,30 @@ class RoutineV1(BaseModel): def __init__(self, **data: Any): super().__init__(**{k: v for k, v in data.items() if v != [] and v != {}}) + @field_validator("connections", mode="after") + @classmethod + def _validate_connections(cls, v, values) -> list[_ConnectionV1]: + children_port_names = [ + f"{child.name}.{port.name}" + for child in values.data.get("children") + for port in child.ports + ] + parent_port_names = [port.name for port in values.data["ports"]] + available_port_names = set(children_port_names + parent_port_names) + + missed_ports = [ + port + for connection in v + for port in (connection.source, connection.target) + if port not in available_port_names + ] + if missed_ports: + raise ValueError( + "The following ports appear in a connection but are not " + "among routine's port or their children's ports: {missed_ports}." + ) + return v + class SchemaV1(BaseModel): """Root object in Program schema V1.""" diff --git a/src/qref/verification.py b/src/qref/verification.py new file mode 100644 index 0000000..1d899a1 --- /dev/null +++ b/src/qref/verification.py @@ -0,0 +1,154 @@ +# Copyright 2024 PsiQuantum, Corp. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from collections import defaultdict +from dataclasses import dataclass +from typing import Optional, Union + +from ._schema_v1 import RoutineV1, SchemaV1 + + +@dataclass +class TopologyVerificationOutput: + """Dataclass containing the output of the topology verification""" + + problems: list[str] + + @property + def is_valid(self): + return len(self.problems) == 0 + + def __bool__(self) -> bool: + return self.is_valid + + +def verify_topology(routine: Union[SchemaV1, RoutineV1]) -> TopologyVerificationOutput: + """Checks whether program has correct topology. + + Args: + routine: Routine or program to be verified. + """ + if isinstance(routine, SchemaV1): + routine = routine.program + problems = _verify_routine_topology(routine) + return TopologyVerificationOutput(problems) + + +def _verify_routine_topology(routine: RoutineV1) -> list[str]: + problems = [] + adjacency_list = _get_adjacency_list_from_routine(routine, path=None) + + problems += _find_cycles(adjacency_list) + problems += _find_disconnected_ports(routine) + + for child in routine.children: + new_problems = _verify_routine_topology(child) + problems += new_problems + return problems + + +def _get_adjacency_list_from_routine( + routine: RoutineV1, path: Optional[str] +) -> dict[str, list[str]]: + """This function creates a flat graph representing one hierarchy level of a routine. + + Nodes represent ports and edges represent connections (they're directed). + Additionaly, we add node for each children and edges coming from all the input ports + into the children, and from the children into all the output ports. + """ + graph = defaultdict(list) + if path is None: + current_path = routine.name + else: + current_path = ".".join([path, routine.name]) + + # First, we go through all the connections and add them as adges to the graph + for connection in routine.connections: + source = ".".join([current_path, connection.source]) + target = ".".join([current_path, connection.target]) + graph[source].append(target) + + # Then for each children we add an extra node and set of connections + for child in routine.children: + input_ports = [] + output_ports = [] + + child_path = ".".join([current_path, child.name]) + for port in child.ports: + if port.direction == "input": + input_ports.append(".".join([child_path, port.name])) + elif port.direction == "output": + output_ports.append(".".join([child_path, port.name])) + + for input_port in input_ports: + graph[input_port].append(child_path) + + graph[child_path] += output_ports + + return graph + + +def _find_cycles(adjacency_list: dict[str, list[str]]) -> list[str]: + # Note: it only returns the first detected cycle. + for node in list(adjacency_list.keys()): + problem = _dfs_iteration(adjacency_list, node) + if problem: + return problem + return [] + + +def _dfs_iteration(adjacency_list, start_node) -> list[str]: + to_visit = [start_node] + visited = [] + predecessors = {} + + while to_visit: + node = to_visit.pop() + visited.append(node) + for neighbour in adjacency_list[node]: + predecessors[neighbour] = node + if neighbour == start_node: + # Reconstruct the cycle + cycle = [neighbour] + while len(cycle) < 2 or cycle[-1] != start_node: + cycle.append(predecessors[cycle[-1]]) + return [f"Cycle detected: {cycle[::-1]}"] + if neighbour not in visited: + to_visit.append(neighbour) + return [] + + +def _find_disconnected_ports(routine: RoutineV1): + problems = [] + for child in routine.children: + for port in child.ports: + pname = f"{routine.name}.{child.name}.{port.name}" + if port.direction == "input": + matches_in = [ + c for c in routine.connections if c.target == f"{child.name}.{port.name}" + ] + if len(matches_in) == 0: + problems.append(f"No incoming connections to {pname}.") + elif len(matches_in) > 1: + problems.append(f"Too many incoming connections to {pname}.") + elif port.direction == "output": + matches_out = [ + c for c in routine.connections if c.source == f"{child.name}.{port.name}" + ] + if len(matches_out) == 0: + problems.append(f"No outgoing connections from {pname}.") + elif len(matches_out) > 1: + problems.append(f"Too many outgoing connections from {pname}.") + + return problems diff --git a/tests/conftest.py b/tests/conftest.py index d966d09..8f7c0e5 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -23,7 +23,7 @@ def _load_valid_examples(): - for path in VALID_PROGRAMS_ROOT_PATH.iterdir(): + for path in sorted(VALID_PROGRAMS_ROOT_PATH.iterdir()): with open(path) as f: data = yaml.safe_load(f) yield pytest.param(data["input"], id=data["description"]) diff --git a/tests/qref/data/invalid_pydantic_programs.yaml b/tests/qref/data/invalid_pydantic_programs.yaml new file mode 100644 index 0000000..3e1ce2c --- /dev/null +++ b/tests/qref/data/invalid_pydantic_programs.yaml @@ -0,0 +1,27 @@ +- input: + version: v1 + program: + name: root + children: + - name: foo + ports: + - name: in_0 + direction: input + size: 3 + - name: out_0 + direction: output + size: 3 + - name: bar + ports: + - name: in_0 + direction: input + size: 3 + - name: out_0 + direction: output + size: 3 + connections: + - source: foo.out_0 + target: bar.in_1 + description: "Connection contains non-existent port name" + error_path: "$.program.connections[0].source" + error_message: "'foo.foo.out_0' does not match '^(([A-Za-z_][A-Za-z0-9_]*)|([A-Za-z_][A-Za-z0-9_]*\\\\.[A-Za-z_][A-Za-z0-9_]*))$'" diff --git a/tests/qref/data/invalid_topology_programs.yaml b/tests/qref/data/invalid_topology_programs.yaml new file mode 100644 index 0000000..d9b2620 --- /dev/null +++ b/tests/qref/data/invalid_topology_programs.yaml @@ -0,0 +1,134 @@ +- input: + program: + children: + - name: child_1 + ports: + - direction: input + name: in_0 + size: 1 + - direction: output + name: out_0 + size: 1 + - direction: input + name: in_1 + size: 1 + - direction: output + name: out_1 + size: 1 + - name: child_2 + ports: + - direction: input + name: in_0 + size: 1 + - direction: output + name: out_0 + size: 1 + - direction: input + name: in_1 + size: 1 + - direction: output + name: out_1 + size: 1 + - name: child_3 + ports: + - direction: input + name: in_0 + size: 1 + - direction: output + name: out_0 + size: 1 + - direction: input + name: in_1 + size: 1 + - direction: output + name: out_1 + size: 1 + + connections: + - source: in_0 + target: child_1.in_0 + - source: child_1.out_0 + target: child_2.in_0 + - source: child_2.out_0 + target: child_3.in_0 + - source: child_3.out_0 + target: out_0 + - source: child_1.out_1 + target: child_2.in_1 + - source: child_2.out_1 + target: child_3.in_1 + - source: child_3.out_1 + target: child_1.in_1 + + name: root + ports: + - direction: input + name: in_0 + size: 1 + - direction: output + name: out_0 + size: 1 + version: v1 + description: Program contains cycles + problems: + - "Cycle detected: ['root.child_1.out_0', 'root.child_2.in_0', 'root.child_2', 'root.child_2.out_1', 'root.child_3.in_1', 'root.child_3', 'root.child_3.out_1', 'root.child_1.in_1', 'root.child_1', 'root.child_1.out_0']" +- input: + program: + children: + - name: child_1 + ports: + - direction: input + name: in_0 + size: 1 + - direction: input + name: in_1 + size: 1 + - direction: output + name: out_0 + size: 1 + - direction: output + name: out_1 + size: 1 + - name: child_2 + ports: + - direction: input + name: in_0 + size: 1 + - direction: output + name: out_0 + size: 1 + - direction: output + name: out_1 + size: 1 + connections: + - source: in_0 + target: child_1.in_0 + - source: child_1.out_0 + target: child_2.in_0 + - source: child_1.out_1 + target: child_2.in_0 + - source: child_2.out_0 + target: out_0 + - source: child_2.out_0 + target: out_1 + + + name: root + ports: + - direction: input + name: in_0 + size: 1 + - direction: output + name: out_0 + size: 1 + - direction: output + name: out_1 + size: 1 + + version: v1 + description: Program has badly connected ports + problems: + - "No incoming connections to root.child_1.in_1." + - "Too many incoming connections to root.child_2.in_0." + - "Too many outgoing connections from root.child_2.out_0." + - "No outgoing connections from root.child_2.out_1." diff --git a/tests/qref/data/invalid_program_examples.yaml b/tests/qref/data/invalid_yaml_programs.yaml similarity index 99% rename from tests/qref/data/invalid_program_examples.yaml rename to tests/qref/data/invalid_yaml_programs.yaml index 8391ea1..a700d2c 100644 --- a/tests/qref/data/invalid_program_examples.yaml +++ b/tests/qref/data/invalid_yaml_programs.yaml @@ -244,3 +244,4 @@ description: "Target of a paramater link is not namespaced" error_path: "$.program.linked_params[0].targets[0]" error_message: "'N' does not match '^[A-Za-z_][A-Za-z0-9_]*\\\\.[A-Za-z_][A-Za-z0-9_]*'" + \ No newline at end of file diff --git a/tests/qref/data/valid_programs/example_2.yaml b/tests/qref/data/valid_programs/example_2.yaml index 7137a4a..463d751 100644 --- a/tests/qref/data/valid_programs/example_2.yaml +++ b/tests/qref/data/valid_programs/example_2.yaml @@ -16,9 +16,6 @@ input: - N name: bar ports: - - direction: input - name: in_0 - size: N - direction: output name: out_0 size: N diff --git a/tests/qref/data/valid_programs/example_4.yaml b/tests/qref/data/valid_programs/example_4.yaml new file mode 100644 index 0000000..be34888 --- /dev/null +++ b/tests/qref/data/valid_programs/example_4.yaml @@ -0,0 +1,112 @@ +description: Program with extended topology +input: + program: + children: + - name: child_1 + ports: + - direction: input + name: in_0 + size: 1 + - direction: output + name: out_0 + size: 1 + - name: child_2 + ports: + - direction: input + name: in_0 + size: 1 + - direction: output + name: out_0 + size: 1 + - name: child_3 + ports: + - direction: input + name: in_0 + size: 1 + - direction: output + name: out_0 + size: 1 + - name: child_4 + ports: + - direction: input + name: in_0 + size: 1 + - direction: output + name: out_0 + size: 1 + - name: child_5 + ports: + - direction: input + name: in_0 + size: 1 + - direction: output + name: out_0 + size: 1 + - name: child_6 + ports: + - direction: input + name: in_0 + size: 1 + - direction: input + name: in_1 + size: 1 + - direction: output + name: out_0 + size: 1 + - direction: output + name: out_1 + size: 1 + - name: child_7 + ports: + - direction: input + name: in_0 + size: 1 + - direction: input + name: in_1 + size: 1 + - direction: output + name: out_0 + size: 1 + + connections: + - source: in_0 + target: child_1.in_0 + - source: in_1 + target: child_4.in_0 + - source: in_2 + target: child_5.in_0 + - source: child_1.out_0 + target: child_2.in_0 + - source: child_2.out_0 + target: child_3.in_0 + - source: child_3.out_0 + target: out_0 + - source: child_4.out_0 + target: child_6.in_0 + - source: child_5.out_0 + target: child_6.in_1 + - source: child_6.out_0 + target: child_7.in_0 + - source: child_6.out_1 + target: child_7.in_1 + - source: child_7.out_0 + target: out_1 + name: root + ports: + - direction: input + name: in_0 + size: 1 + - direction: input + name: in_1 + size: 1 + - direction: input + name: in_2 + size: 1 + - direction: output + name: out_0 + size: 1 + - direction: output + name: out_1 + size: 1 + + version: v1 diff --git a/tests/qref/experimental/test_rendering.py b/tests/qref/experimental/test_rendering.py index 73e95c2..a4d6bc0 100644 --- a/tests/qref/experimental/test_rendering.py +++ b/tests/qref/experimental/test_rendering.py @@ -12,8 +12,6 @@ # See the License for the specific language governing permissions and # limitations under the License. -"""Sanity checks for QREF rendering capabilities.""" - import json from subprocess import Popen diff --git a/tests/qref/test_schema_validation.py b/tests/qref/test_schema_validation.py index 5b591ba..e202ea3 100644 --- a/tests/qref/test_schema_validation.py +++ b/tests/qref/test_schema_validation.py @@ -12,8 +12,6 @@ # See the License for the specific language governing permissions and # limitations under the License. -"""Test cases checking that schema matches data that we expect it to match.""" - from pathlib import Path import pydantic @@ -28,10 +26,15 @@ def validate_with_v1(data): validate(data, generate_program_schema(version="v1")) -def load_invalid_examples(): - with open(Path(__file__).parent / "data/invalid_program_examples.yaml") as f: +def load_invalid_examples(add_pydantic=False): + with open(Path(__file__).parent / "data/invalid_yaml_programs.yaml") as f: data = yaml.safe_load(f) + if add_pydantic: + with open(Path(__file__).parent / "data/invalid_pydantic_programs.yaml") as f: + additional_data = yaml.safe_load(f) + data += additional_data + return [ pytest.param( example["input"], @@ -56,7 +59,7 @@ def test_valid_program_successfully_validates_with_schema_v1(valid_program): validate_with_v1(valid_program) -@pytest.mark.parametrize("input", [input for input, *_ in load_invalid_examples()]) +@pytest.mark.parametrize("input", [input for input, *_ in load_invalid_examples(add_pydantic=True)]) def test_invalid_program_fails_to_validate_with_pydantic_model_v1(input): with pytest.raises(pydantic.ValidationError): SchemaV1.model_validate(input) diff --git a/tests/qref/test_topology_verification.py b/tests/qref/test_topology_verification.py new file mode 100644 index 0000000..e38e37a --- /dev/null +++ b/tests/qref/test_topology_verification.py @@ -0,0 +1,51 @@ +# Copyright 2024 PsiQuantum, Corp. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from pathlib import Path + +import pytest +import yaml + +from qref import SchemaV1 +from qref.verification import verify_topology + + +def load_invalid_examples(): + with open(Path(__file__).parent / "data/invalid_topology_programs.yaml") as f: + data = yaml.safe_load(f) + + return [ + pytest.param( + example["input"], + example["problems"], + id=example["description"], + ) + for example in data + ] + + +def test_correct_routines_pass_topology_validation(valid_program): + verification_output = verify_topology(SchemaV1(**valid_program)) + assert verification_output + assert len(verification_output.problems) == 0 + + +@pytest.mark.parametrize("input, problems", load_invalid_examples()) +def test_invalid_program_fails_to_validate_with_schema_v1(input, problems): + verification_output = verify_topology(SchemaV1(**input)) + + assert not verification_output + assert len(problems) == len(verification_output.problems) + for expected_problem, problem in zip(problems, verification_output.problems): + assert expected_problem == problem