From 3d1f169c78475af9550caf8dbbc71e096c89bfb2 Mon Sep 17 00:00:00 2001 From: Maarten Sebregts Date: Wed, 15 Jul 2026 13:13:32 +0200 Subject: [PATCH 01/10] Additional dunder methods for Ports and Timeline --- ymmsl/v0_2/ports.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/ymmsl/v0_2/ports.py b/ymmsl/v0_2/ports.py index 5bfa6a0..ed2a80e 100644 --- a/ymmsl/v0_2/ports.py +++ b/ymmsl/v0_2/ports.py @@ -109,6 +109,10 @@ def __getitem__(self, index: int) -> Reference: """Return the index'th item in the timeline.""" return self._parts[index] + def __iter__(self) -> Iterator[Reference]: + """Iterate over the timeline parts.""" + yield from self._parts + def __add__(self, other: Any) -> "Timeline": """Concatenate this timeline with another (relative!) Timeline.""" if isinstance(other, Timeline): @@ -285,6 +289,15 @@ def __iter__(self) -> Iterator[Identifier]: """Iterate through the ports' names.""" yield from self._ports + def items(self) -> Iterator[tuple[Identifier, Port]]: + yield from self._ports.items() + + def keys(self) -> Iterator[Identifier]: + yield from self._ports.keys() + + def values(self) -> Iterator[Port]: + yield from self._ports.values() + def sending_port_names(self) -> List[Identifier]: """Return the names of all the sending ports. From 40d6bef11f74f6febf40019dd9fe7f09030e7ade Mon Sep 17 00:00:00 2001 From: Maarten Sebregts Date: Wed, 15 Jul 2026 13:14:47 +0200 Subject: [PATCH 02/10] Fix potential typing issue Signature accepts a Sequence, but the logic previously only worked when providing a list. Other sequences silently didn't work. --- ymmsl/v0_2/ports.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ymmsl/v0_2/ports.py b/ymmsl/v0_2/ports.py index ed2a80e..1324fe8 100644 --- a/ymmsl/v0_2/ports.py +++ b/ymmsl/v0_2/ports.py @@ -76,7 +76,7 @@ def make_new_reference(x: Union[str, Reference]) -> Reference: self._parts = list(map(make_new_reference, parts)) - elif isinstance(timeline, list): + else: self.absolute = absolute self._parts = list(map(make_new_reference, timeline)) From 56ebfb207ed0b0e555759895f066596c1a7b4ab0 Mon Sep 17 00:00:00 2001 From: Maarten Sebregts Date: Wed, 15 Jul 2026 15:39:53 +0200 Subject: [PATCH 03/10] Extend Timeline API Add `parent` property and `relative_to` (inspired by pathlib.Path) --- ymmsl/v0_2/ports.py | 19 +++++++++++++++++++ ymmsl/v0_2/tests/test_ports.py | 21 +++++++++++++++++++++ 2 files changed, 40 insertions(+) diff --git a/ymmsl/v0_2/ports.py b/ymmsl/v0_2/ports.py index 1324fe8..9ab9147 100644 --- a/ymmsl/v0_2/ports.py +++ b/ymmsl/v0_2/ports.py @@ -123,6 +123,25 @@ def __add__(self, other: Any) -> "Timeline": return Timeline(self._parts + other._parts, self.absolute) return NotImplemented + @property + def parent(self) -> "Timeline | None": + """Get the parent of this timeline. Returns None when there is no parent.""" + if not self._parts: + return None + return Timeline(self._parts[:-1], self.absolute) + + def relative_to(self, other: "Timeline") -> "Timeline": + """Compute a version of this timeline relative to `other`. + + Both timelines must be absolute, and the other timeline must be a parent + timeline of this one. + """ + if not self.absolute or not other.absolute: + raise ValueError("Both timelines must be absolute") + if self._parts[: len(other)] != other._parts: + raise ValueError(f"{self} is not a subtimeline of {other}") + return Timeline(self._parts[len(other) :], False) + class Port: """A port on a component. diff --git a/ymmsl/v0_2/tests/test_ports.py b/ymmsl/v0_2/tests/test_ports.py index 438358b..44cd4fb 100644 --- a/ymmsl/v0_2/tests/test_ports.py +++ b/ymmsl/v0_2/tests/test_ports.py @@ -125,6 +125,27 @@ def test_timeline_concatenate_empty() -> None: assert tl4 == tl1 +def test_timeline_parent() -> None: + assert Timeline("").parent is None + assert Timeline(":").parent is None + + assert Timeline("a:b").parent == Timeline("a") + assert Timeline(":a:b").parent == Timeline(":a") + + +def test_timeline_relative_to() -> None: + assert Timeline(":a:b:c").relative_to(Timeline(":a:b")) == Timeline("c") + assert Timeline(":a:b:c").relative_to(Timeline(":a")) == Timeline("b:c") + + with pytest.raises(ValueError, match="absolute"): + Timeline("a:b").relative_to(Timeline(":a")) + with pytest.raises(ValueError, match="absolute"): + Timeline(":a:b").relative_to(Timeline("a")) + + with pytest.raises(ValueError, match="subtimeline"): + Timeline(":a:b").relative_to(Timeline(":b")) + + def test_create_empty_ports() -> None: p = Ports() assert len(p._ports) == 0 From a093eca1c21b2cfce2498dce7ce6cabac3cf58f7 Mon Sep 17 00:00:00 2001 From: Maarten Sebregts Date: Thu, 16 Jul 2026 14:15:41 +0200 Subject: [PATCH 04/10] Add timeline checker and testcases --- ymmsl/v0_2/component.py | 6 +- ymmsl/v0_2/tests/test_resolve_timelines.py | 141 +++++++++++ ymmsl/v0_2/tests/ymmsl1/timelines.ymmsl | 219 ++++++++++++++++ ymmsl/v0_2/timeline_resolver.py | 280 +++++++++++++++++++++ 4 files changed, 645 insertions(+), 1 deletion(-) create mode 100644 ymmsl/v0_2/tests/test_resolve_timelines.py create mode 100644 ymmsl/v0_2/tests/ymmsl1/timelines.ymmsl create mode 100644 ymmsl/v0_2/timeline_resolver.py diff --git a/ymmsl/v0_2/component.py b/ymmsl/v0_2/component.py index e04b340..2dcbd34 100644 --- a/ymmsl/v0_2/component.py +++ b/ymmsl/v0_2/component.py @@ -5,7 +5,7 @@ from ymmsl.util import remove_trailing_whitespace from ymmsl.v0_2.identity import Reference -from ymmsl.v0_2.ports import Ports +from ymmsl.v0_2.ports import Ports, Timeline class Component: @@ -30,6 +30,9 @@ class Component: implementation: A Model or Program implementing this component optional: Whether this component is optional multiplicity: The shape of the set of instances + timeline: The resolved (absolute) timeline for this component inside the model. + This will be ``None`` until resolved by + :meth:`~ymmsl.v0_2.timeline_resolver.resolve_timelines()`. """ def __init__( @@ -56,6 +59,7 @@ def __init__( self.ports = ports self.description = description self.optional = optional + self.timeline: Optional[Timeline] = None if implementation is not None: self.implementation: Optional[Reference] = Reference(implementation) diff --git a/ymmsl/v0_2/tests/test_resolve_timelines.py b/ymmsl/v0_2/tests/test_resolve_timelines.py new file mode 100644 index 0000000..40d5dd8 --- /dev/null +++ b/ymmsl/v0_2/tests/test_resolve_timelines.py @@ -0,0 +1,141 @@ +from pathlib import Path + +import pytest + +import ymmsl +from ymmsl.v0_2 import ConduitFilter, Configuration, Timeline +from ymmsl.v0_2 import Reference as Ref +from ymmsl.v0_2.timeline_resolver import ( + ROOT_TIMELINE, + ConduitTimelineError, + CyclicDependency, + InconsistentTimelines, + TooManyReducerFilters, + resolve_timelines, +) + + +@pytest.fixture() +def timelines_configuration() -> Configuration: + return ymmsl.load_as( + Configuration, Path(__file__).parent / "ymmsl1/timelines.ymmsl" + ) + + +def test_consistent_configuration(timelines_configuration: Configuration) -> None: + timelines_configuration.check_consistent() + + +def test_dispatch(timelines_configuration: Configuration) -> None: + model = timelines_configuration.models[Ref("dispatch")] + resolve_timelines(model) + assert model.components[Ref("first")].timeline == ROOT_TIMELINE + assert model.components[Ref("second")].timeline == ROOT_TIMELINE + + +def test_macromicro(timelines_configuration: Configuration) -> None: + model = timelines_configuration.models[Ref("macromicro")] + resolve_timelines(model) + assert model.components[Ref("macro")].timeline == ROOT_TIMELINE + assert model.components[Ref("micro")].timeline == Timeline(":macro") + + # Check that ports have the correct relative timelines: + macro = model.components[Ref("macro")] + assert macro.ports["init"].timeline == Timeline("") + assert macro.ports["out"].timeline == Timeline("macro") + assert macro.ports["in"].timeline == Timeline("macro") + + for micro_port in model.components[Ref("micro")].ports.values(): + assert micro_port.timeline == Timeline("") + + +def test_cycle(timelines_configuration: Configuration) -> None: + with pytest.raises(CyclicDependency, match="cycle in model"): + resolve_timelines(timelines_configuration.models[Ref("cycle")]) + + +def test_reducer(timelines_configuration: Configuration) -> None: + model = timelines_configuration.models[Ref("reducer")] + resolve_timelines(model) + assert model.components[Ref("first")].timeline == ROOT_TIMELINE + assert model.components[Ref("second")].timeline == ROOT_TIMELINE + + +def test_too_many_reducers(timelines_configuration: Configuration) -> None: + model = timelines_configuration.models[Ref("reducer")] + model.conduits[-1].filters.append(model.conduits[-1].filters[0]) + with pytest.raises(TooManyReducerFilters, match="too many reducer filters"): + resolve_timelines(model) + + +def test_inconsistent_timelines(timelines_configuration: Configuration) -> None: + with pytest.raises(InconsistentTimelines): + resolve_timelines(timelines_configuration.models[Ref("inconsistent")]) + + +def test_repeaters(timelines_configuration: Configuration) -> None: + model = timelines_configuration.models[Ref("repeaters")] + resolve_timelines(model) + assert model.components[Ref("macro")].timeline == ROOT_TIMELINE + assert model.components[Ref("meso")].timeline == Timeline(":macro") + assert model.components[Ref("micro")].timeline == Timeline(":macro:meso") + + +def test_too_many_repeaters(timelines_configuration: Configuration) -> None: + model = timelines_configuration.models[Ref("repeaters")] + model.conduits[-2].filters.append(ConduitFilter.REPEAT) + with pytest.raises(ConduitTimelineError, match="remove a repeater"): + resolve_timelines(model) + + +def test_too_few_repeaters(timelines_configuration: Configuration) -> None: + model = timelines_configuration.models[Ref("repeaters")] + model.conduits[-1].filters.pop() + with pytest.raises(ConduitTimelineError, match="add a repeater"): + resolve_timelines(model) + + +def test_repeater_and_too_many_reducers(timelines_configuration: Configuration) -> None: + model = timelines_configuration.models[Ref("repeaters")] + model.conduits[-1].filters.insert(0, ConduitFilter.LAST) + with pytest.raises(TooManyReducerFilters, match="too many reducer filters"): + resolve_timelines(model) + + +def test_repeater_after_reducer(timelines_configuration: Configuration) -> None: + model = timelines_configuration.models[Ref("repeater_reducer")] + resolve_timelines(model) + assert model.components[Ref("macro1")].timeline == ROOT_TIMELINE + assert model.components[Ref("macro2")].timeline == ROOT_TIMELINE + assert model.components[Ref("micro1")].timeline == Timeline(":macro1") + assert model.components[Ref("micro2")].timeline == Timeline(":macro2") + + # Remove filters on the last conduit to make the incoming timelines inconsistent + model.conduits[-1].filters = [] + with pytest.raises(InconsistentTimelines, match="different timelines"): + resolve_timelines(model) + + +def test_repeater_after_reducer_error(timelines_configuration: Configuration) -> None: + model = timelines_configuration.models[Ref("repeater_reducer_error")] + with pytest.raises(ConduitTimelineError, match="remove a repeater and reducer"): + resolve_timelines(model) + model.conduits[-1].filters = [] + resolve_timelines(model) + assert model.components[Ref("macro")].timeline == ROOT_TIMELINE + assert model.components[Ref("micro1")].timeline == Timeline(":macro") + assert model.components[Ref("micro2")].timeline == Timeline(":macro") + + +def test_inconsistent_interact(timelines_configuration: Configuration) -> None: + model = timelines_configuration.models[Ref("inconsistent_interact")] + with pytest.raises(ConduitTimelineError, match="missing timeline annotations"): + resolve_timelines(model) + model.components[Ref("B")].ports["out"].timeline = Timeline("A") + model.components[Ref("B")].ports["in"].timeline = Timeline("A") + resolve_timelines(model) + + +def test_model_ports(timelines_configuration: Configuration) -> None: + model = timelines_configuration.models[Ref("model_ports")] + resolve_timelines(model) diff --git a/ymmsl/v0_2/tests/ymmsl1/timelines.ymmsl b/ymmsl/v0_2/tests/ymmsl1/timelines.ymmsl new file mode 100644 index 0000000..313274b --- /dev/null +++ b/ymmsl/v0_2/tests/ymmsl1/timelines.ymmsl @@ -0,0 +1,219 @@ +ymmsl_version: v0.2 +description: Test models for determining timelines +models: + dispatch: + ports: + f_init: init + components: + first: + description: First component + ports: + f_init: init + o_f: final + second: + description: Second component + ports: + f_init: init + conduits: + init: first.init + first.final: second.init + + macromicro: + ports: + f_init: init + components: + macro: + description: Macro component + ports: + f_init: init + o_i: out + s: in + micro: + description: Micro component + ports: + f_init: init1 init2 + o_f: final + conduits: + init: + - macro.init + - repeat micro.init1 # Test repeater filter + macro.out: micro.init2 + micro.final: macro.in + + cycle: + components: + first: + description: First component + ports: + f_init: init + o_f: final + second: + description: Second component + ports: + f_init: init + o_f: final + conduits: + first.final: second.init + second.final: first.init + + reducer: + components: + first: + description: First component + ports: + o_i: out + o_f: final + second: + description: Second component + ports: + f_init: init1 init2 + conduits: + first.final: second.init1 + first.out: last second.init2 + + inconsistent: + components: + macro: + description: Macro component + ports: + o_i: out + o_f: final + micro: + description: Micro component + ports: + f_init: in + o_f: final + inconsistent: + description: Component with inconsistent timelines + ports: + f_init: init1 init2 + conduits: + macro.out: micro.in + macro.final: inconsistent.init1 + micro.final: inconsistent.init2 + + repeaters: + ports: + f_init: init + components: + macro: + description: Macro component + ports: + f_init: init + o_i: out + s: in + meso: + description: Meso component + ports: + f_init: init init2 + o_i: out + s: in + o_f: final + micro: + description: Micro component + ports: + f_init: init init2 + o_f: final + conduits: + macro.out: meso.init + meso.final: macro.in + meso.out: micro.init + micro.final: meso.in + init: + - macro.init + - repeat meso.init2 + - pad pad micro.init2 + + repeater_reducer: + components: + macro1: + description: First macro component + ports: + o_i: out + o_f: final + micro1: + description: First micro + ports: + f_init: init + o_f: final + macro2: + description: Second macro component + ports: + f_init: init + o_i: out + micro2: + description: Second micro + ports: + f_init: init_micro1 init_macro2 + conduits: + macro1.out: micro1.init + macro1.final: macro2.init + macro2.out: micro2.init_macro2 + micro1.final: last repeat micro2.init_micro1 + + repeater_reducer_error: + components: + macro: + description: Macro component + ports: + o_i: out + micro1: + description: First micro + ports: + f_init: init + o_f: final + micro2: + description: Second micro + ports: + f_init: init1 init2 + conduits: + macro.out: + - micro1.init + - micro2.init1 + micro1.final: last pad micro2.init2 + + inconsistent_interact: + components: + A: + description: A + ports: + o_i: out + s: in + B: + description: A + ports: + o_i: out + s: in + conduits: + A.out: B.in + B.out: A.in + + model_ports: + components: + macro: + description: macro + ports: + f_init: init + o_i: out + s: in + o_f: final + meso: + description: meso + ports: + f_init: init + o_i: out + s: in + o_f: final + ports: + f_init: init + +macro:meso: + o_i: out + s: in + o_f: final + conduits: + init: macro.init + macro.out: meso.init + meso.out: out + in: meso.in + meso.final: macro.in + macro.final: final diff --git a/ymmsl/v0_2/timeline_resolver.py b/ymmsl/v0_2/timeline_resolver.py new file mode 100644 index 0000000..517fb71 --- /dev/null +++ b/ymmsl/v0_2/timeline_resolver.py @@ -0,0 +1,280 @@ +from ymmsl.v0_2 import ( + Component, + Conduit, + Identifier, + Model, + Operator, + Port, + Reference, + Timeline, +) + +ROOT_TIMELINE = Timeline(":") + + +def resolve_timelines(model: Model) -> None: + """Determine timelines for each component and their O_I and S ports in this model. + + An exception will be raised when the model timelines are not consistent. This + function updates the timeline attributes of the components and ports in the model. + """ + checker = TimelineChecker(model) + checker.check_consistent() + + # Update timeline attributes + for component in model.components.values(): + component.timeline = checker.component_timeline(component.name) + for port in component.ports.values(): + full_port_name = component.name + port.name + timeline = checker.timeline_for_port(full_port_name) + port.timeline = timeline.relative_to(component.timeline) + + +class CyclicDependency(RuntimeError): + """Error raised when some models form a dependency cycle. + + Dependency cycles occur when messages to an F_INIT port of a component depend in + some way on the output of that component. + """ + + def __init__(self, model: Model, cycle: list[Component]) -> None: + self.model = model + self.cycle = cycle + msg = f"Detected a dependency cycle in model '{model.name}': " + " -> ".join( + str(component.name) for component in cycle + ) + super().__init__(msg) + + +class TooManyReducerFilters(RuntimeError): + """Error raised when a conduit has too many reducer filters applied.""" + + def __init__( + self, model: Model, conduit: Conduit, sender_timeline: Timeline + ) -> None: + self.model = model + self.conduit = conduit + self.sender_timeline = sender_timeline + num_reducers = sum(filter.is_reducer() for filter in conduit.filters) + msg = ( + f"{conduit} in model '{model.name}' has too many reducer filters. The " + f"sending port ({conduit.sender}) has timeline ({sender_timeline}) and " + f"can only be reduced {len(sender_timeline)} times, but there are " + f"{num_reducers} reducer filters." + ) + super().__init__(msg) + + +class InconsistentTimelines(RuntimeError): + """Error raised when a component's F_INIT ports have inconsistent timelines.""" + + def __init__( + self, + model: Model, + component: Component, + conduits: list[Conduit], + timelines: list[Timeline], + ) -> None: + self.model = model + self.component = component + self.conduits = conduits + self.timelines = timelines + msg = ( + f"Component '{component.name}' in model '{model.name}' has different " + f"timelines for the following F_INIT ports:\n" + + "\n".join( + f"- Port '{conduit.receiving_port()}' has timeline '{timeline}' " + f"from {conduit}" + for conduit, timeline in zip(conduits, timelines) + ) + ) + super().__init__(msg) + + +class ConduitTimelineError(RuntimeError): + """Error raised for conduits that connect incompatible timelines.""" + + def __init__( + self, + checker: "TimelineChecker", + conduit: Conduit, + timeline1: Timeline, + timeline2: Timeline, + hint: str = "", + ) -> None: + self.checker = checker + model = checker._model + self.conduit = conduit + self.timeline1 = timeline1 + self.timeline2 = timeline2 + self.hint = hint + msg = ( + f"{conduit} in model '{model.name}' has inconsistent timelines: it " + f"connects timeline '{timeline1}' with timeline '{timeline2}', but this " + f"does not match with the filters of the conduit.{hint} Note that this " + "error may also be caused by missing timeline annotations for O_I and S " + "ports, or because the sending or receiving component has incorrect " + "F_INIT conduits. Determined timelines per component:\n" + f"{checker.format_timelines()}" + ) + super().__init__(msg) + + +class TimelineChecker: + """Checks timelines and nesting for a given yMMSL model""" + + def __init__(self, model: Model) -> None: + self._model = model + """yMMSL model that is checked.""" + + self._component_timeline: dict[Reference, Timeline] = { + Reference([]): ROOT_TIMELINE, # Support model ports + } + """Map of component names to the TimelineNode they are part of.""" + self._conduits_by_receiver: dict[Reference, Conduit] = { + conduit.receiver: conduit for conduit in self._model.conduits + } + """Map of conduits by their receiving port""" + self._all_ports: dict[Reference, Port] = { + component.name + port_name: port + for component in self._model.components.values() + for port_name, port in component.ports.items() + } + """Map of Port objects by their full reference""" + + # Assign components to timelines + for component in self._model.components.values(): + if component.name in self._component_timeline: + continue + self._assign_component(component, []) + + def _f_init_conduits_for_component(self, component: Component) -> list[Conduit]: + """Get conduits that are connected to an F_INIT port on the component""" + result = [] + for port in component.ports.values(): + if port.operator is Operator.F_INIT: + conduit = self._conduits_by_receiver.get(component.name + port.name) + if conduit is not None: + result.append(conduit) + return result + + def _assign_component(self, component: Component, seen: list[Component]) -> None: + """Recursive component assignment, uses "seen" list for cycle detection.""" + if component in seen: + idx = seen.index(component) + cycle = seen[idx:] + [component] + raise CyclicDependency(self._model, cycle) + f_init_conduits = self._f_init_conduits_for_component(component) + + # Ensure we know the timelines of the components attached to our F_INIT + seen.append(component) + for conduit in f_init_conduits: + sender = conduit.sending_component() + if sender not in self._component_timeline: + self._assign_component(self._model.components[sender], seen) + seen.pop() + + # Now we can determine our timeline + incoming_timelines: list[Timeline] = [] + checked_conduits: list[Conduit] = [] # To provide better error messages + for conduit in f_init_conduits: + if any(filter.is_repeater() for filter in conduit.filters): + continue # We cannot use repeater filters to determine the timeline + timeline = sender_timeline = self.timeline_for_port(conduit.sender) + for filter in conduit.filters: + assert filter.is_reducer() + if timeline.parent is None: + raise TooManyReducerFilters(self._model, conduit, sender_timeline) + timeline = timeline.parent + incoming_timelines.append(timeline) + checked_conduits.append(conduit) + + determined_timeline = ROOT_TIMELINE + if incoming_timelines: + determined_timeline = incoming_timelines[0] + if not all(tl == determined_timeline for tl in incoming_timelines): + raise InconsistentTimelines( + self._model, component, checked_conduits, incoming_timelines + ) + + # Done: register timeline for component + self._component_timeline[component.name] = determined_timeline + + def timeline_for_port(self, port_name: Reference) -> Timeline: + """Determine the timeline for messages sent or received on the provided port + name.""" + component = port_name[:-1] + if len(component) == 0: + # Connected to a model port + model_port = port_name[-1] + assert isinstance(model_port, Identifier) + port = self._model.ports[model_port] + return ROOT_TIMELINE + port.timeline + timeline = self._component_timeline[component] + port = self._all_ports[port_name] + if port.operator in (Operator.O_F, Operator.F_INIT): + return timeline + subtimeline = port.timeline + if len(port.timeline) == 0: + # No explicit label attached to the timeline, so we take the component name + subtimeline = Timeline(str(component)) + return timeline + subtimeline + + def component_timeline(self, component: Reference) -> Timeline: + """Get the determined timeline for a component in the model""" + return self._component_timeline[component] + + def check_consistent(self) -> None: + """Check if the timelines are consistent. + + N.B. Certain inconsistencies prevent initialization. This method performs + additional checks. + """ + # Check that all conduits connect consistently + for conduit in self._model.conduits: + timeline1 = self.timeline_for_port(conduit.sender) + timeline2 = self.timeline_for_port(conduit.receiver) + + num_reducers = sum(filter.is_reducer() for filter in conduit.filters) + num_repeaters = sum(filter.is_repeater() for filter in conduit.filters) + num_filters = len(conduit.filters) + assert num_reducers + num_repeaters == num_filters + + # Apply reducers + if len(timeline1) < num_reducers: + raise TooManyReducerFilters(self._model, conduit, timeline1) + if len(timeline1) - num_reducers + num_repeaters != len(timeline2): + remove_msg = "" + if len(timeline1) - num_reducers + num_repeaters < len(timeline2): + add_filter = "repeater ('pad' or 'repeat')" + if num_reducers > 0: + remove_msg = "reducer ('last')" + else: + add_filter = "reducer ('last')" + if num_repeaters > 0: + remove_msg = "repeater ('pad' or 'repeat')" + if remove_msg: + remove_msg = f" or remove a {remove_msg} filter" + hint = f" You may need to add a {add_filter} filter{remove_msg}." + raise ConduitTimelineError(self, conduit, timeline1, timeline2, hint) + + # Check consistency + common_idx = len(timeline1) - num_reducers + for idx, (part1, part2) in enumerate(zip(timeline1, timeline2)): + if idx < common_idx: + if part1 != part2: + raise ConduitTimelineError(self, conduit, timeline1, timeline2) + else: + if part1 == part2: + hint = " You may need to remove a repeater and reducer filter." + raise ConduitTimelineError( + self, conduit, timeline1, timeline2, hint + ) + + def format_timelines(self) -> str: + """Create a formatted list of determined timelines per component.""" + return "\n".join( + f"- Component '{comp}' has timeline '{tl}'" + for comp, tl in self._component_timeline.items() + if len(comp) > 0 # Ony print actual components + ) From 7a1a4e16baf8fe28d14fa7fc3cca5acee3c10a7b Mon Sep 17 00:00:00 2001 From: Maarten Sebregts Date: Thu, 16 Jul 2026 14:20:58 +0200 Subject: [PATCH 05/10] Update public API in v0.2/__init__.py --- ymmsl/v0_2/__init__.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/ymmsl/v0_2/__init__.py b/ymmsl/v0_2/__init__.py index 0f25f54..31c274a 100644 --- a/ymmsl/v0_2/__init__.py +++ b/ymmsl/v0_2/__init__.py @@ -27,6 +27,13 @@ SupportedSetting, SupportedSettings, ) +from ymmsl.v0_2.timeline_resolver import ( + ConduitTimelineError, + CyclicDependency, + InconsistentTimelines, + TooManyReducerFilters, + resolve_timelines, +) __all__ = [ "BaseEnv", @@ -38,13 +45,16 @@ "Ports", "Conduit", "ConduitFilter", + "ConduitTimelineError", "Configuration", + "CyclicDependency", "Document", "ExecutionModel", "Identifier", "Implementation", "ImportKind", "ImportStatement", + "InconsistentTimelines", "KeepsStateForNextUse", "Model", "MPICoresResReq", @@ -56,6 +66,7 @@ "Reference", "ReferencePart", "resolve", + "resolve_timelines", "ResourceRequirements", "Settings", "SettingType", @@ -64,4 +75,5 @@ "SupportedSettings", "ThreadedResReq", "Timeline", + "TooManyReducerFilters", ] From 016a815b32cac4fe4aa3089248058dd265d14ad1 Mon Sep 17 00:00:00 2001 From: Maarten Sebregts Date: Thu, 16 Jul 2026 14:31:47 +0200 Subject: [PATCH 06/10] Rename test module --- .../{test_resolve_timelines.py => test_timeline_resolver.py} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename ymmsl/v0_2/tests/{test_resolve_timelines.py => test_timeline_resolver.py} (100%) diff --git a/ymmsl/v0_2/tests/test_resolve_timelines.py b/ymmsl/v0_2/tests/test_timeline_resolver.py similarity index 100% rename from ymmsl/v0_2/tests/test_resolve_timelines.py rename to ymmsl/v0_2/tests/test_timeline_resolver.py From 849daa7fa96f65a3cfa26be6041bd6b0acdac54c Mon Sep 17 00:00:00 2001 From: Maarten Sebregts Date: Fri, 17 Jul 2026 12:15:20 +0200 Subject: [PATCH 07/10] Improve error message for cyclic dependencies Include information on the conduits instead of just the components. Example error message: > Detected a dependency cycle in model 'cycle'. The component 'first' has an F_INIT port that depends on data produced by one of its own O_F or O_I ports: Conduit(first.final -> second.init) -> Conduit(second.final -> third.init) -> Conduit(third.final -> first.init). You may have an error in the conduits or may need a different coupling schema. --- ymmsl/v0_2/tests/ymmsl1/timelines.ymmsl | 8 ++++++- ymmsl/v0_2/timeline_resolver.py | 28 ++++++++++++++++++------- 2 files changed, 27 insertions(+), 9 deletions(-) diff --git a/ymmsl/v0_2/tests/ymmsl1/timelines.ymmsl b/ymmsl/v0_2/tests/ymmsl1/timelines.ymmsl index 313274b..ab45457 100644 --- a/ymmsl/v0_2/tests/ymmsl1/timelines.ymmsl +++ b/ymmsl/v0_2/tests/ymmsl1/timelines.ymmsl @@ -52,9 +52,15 @@ models: ports: f_init: init o_f: final + third: + description: Third component + ports: + f_init: init + o_f: final conduits: first.final: second.init - second.final: first.init + second.final: third.init + third.final: first.init reducer: components: diff --git a/ymmsl/v0_2/timeline_resolver.py b/ymmsl/v0_2/timeline_resolver.py index 517fb71..62d99f7 100644 --- a/ymmsl/v0_2/timeline_resolver.py +++ b/ymmsl/v0_2/timeline_resolver.py @@ -37,13 +37,19 @@ class CyclicDependency(RuntimeError): some way on the output of that component. """ - def __init__(self, model: Model, cycle: list[Component]) -> None: + def __init__( + self, model: Model, cycle: list[Component], conduits: list[Conduit] + ) -> None: self.model = model self.cycle = cycle - msg = f"Detected a dependency cycle in model '{model.name}': " + " -> ".join( - str(component.name) for component in cycle + self.conduits = conduits + cycle_str = " -> ".join(str(conduit) for conduit in reversed(conduits)) + super().__init__( + f"Detected a dependency cycle in model '{model.name}'. The component " + f"'{cycle[0].name}' has an F_INIT port that depends on data produced by " + f"one of its own O_F or O_I ports: {cycle_str}. You may have an error " + "in the conduits or may need a different coupling schema." ) - super().__init__(msg) class TooManyReducerFilters(RuntimeError): @@ -146,7 +152,7 @@ def __init__(self, model: Model) -> None: for component in self._model.components.values(): if component.name in self._component_timeline: continue - self._assign_component(component, []) + self._assign_component(component, [], []) def _f_init_conduits_for_component(self, component: Component) -> list[Conduit]: """Get conduits that are connected to an F_INIT port on the component""" @@ -158,12 +164,14 @@ def _f_init_conduits_for_component(self, component: Component) -> list[Conduit]: result.append(conduit) return result - def _assign_component(self, component: Component, seen: list[Component]) -> None: + def _assign_component( + self, component: Component, seen: list[Component], seen_conduits: list[Conduit] + ) -> None: """Recursive component assignment, uses "seen" list for cycle detection.""" if component in seen: idx = seen.index(component) cycle = seen[idx:] + [component] - raise CyclicDependency(self._model, cycle) + raise CyclicDependency(self._model, cycle, seen_conduits[idx:]) f_init_conduits = self._f_init_conduits_for_component(component) # Ensure we know the timelines of the components attached to our F_INIT @@ -171,7 +179,11 @@ def _assign_component(self, component: Component, seen: list[Component]) -> None for conduit in f_init_conduits: sender = conduit.sending_component() if sender not in self._component_timeline: - self._assign_component(self._model.components[sender], seen) + seen_conduits.append(conduit) + self._assign_component( + self._model.components[sender], seen, seen_conduits + ) + seen_conduits.pop() seen.pop() # Now we can determine our timeline From f98cc6b70c5cb42b433d033d2879083c6914e0b6 Mon Sep 17 00:00:00 2001 From: Maarten Sebregts Date: Fri, 17 Jul 2026 13:41:01 +0200 Subject: [PATCH 08/10] Fix docstring link --- ymmsl/v0_2/component.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ymmsl/v0_2/component.py b/ymmsl/v0_2/component.py index 2dcbd34..c731fd8 100644 --- a/ymmsl/v0_2/component.py +++ b/ymmsl/v0_2/component.py @@ -32,7 +32,7 @@ class Component: multiplicity: The shape of the set of instances timeline: The resolved (absolute) timeline for this component inside the model. This will be ``None`` until resolved by - :meth:`~ymmsl.v0_2.timeline_resolver.resolve_timelines()`. + :meth:`~ymmsl.v0_2.resolve_timelines()`. """ def __init__( From 78280ffb916940ffca56d424177ff090b3b9d56a Mon Sep 17 00:00:00 2001 From: Maarten Sebregts Date: Mon, 20 Jul 2026 10:49:30 +0200 Subject: [PATCH 09/10] Update timeline annotation in test --- ymmsl/v0_2/tests/ymmsl1/timelines.ymmsl | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ymmsl/v0_2/tests/ymmsl1/timelines.ymmsl b/ymmsl/v0_2/tests/ymmsl1/timelines.ymmsl index ab45457..8ce6b22 100644 --- a/ymmsl/v0_2/tests/ymmsl1/timelines.ymmsl +++ b/ymmsl/v0_2/tests/ymmsl1/timelines.ymmsl @@ -212,7 +212,7 @@ models: o_f: final ports: f_init: init - +macro:meso: + timeline macro:meso: o_i: out s: in o_f: final From 7f7add8a2e5fd8c80abbbd0aaebb1e3f822ce240 Mon Sep 17 00:00:00 2001 From: Maarten Sebregts Date: Tue, 21 Jul 2026 14:33:35 +0200 Subject: [PATCH 10/10] Improve exceptions, docstrings and add testcase Based on PR feedback --- ymmsl/v0_2/__init__.py | 2 ++ ymmsl/v0_2/tests/test_timeline_resolver.py | 10 ++++++ ymmsl/v0_2/timeline_resolver.py | 40 +++++++++++++++++----- 3 files changed, 43 insertions(+), 9 deletions(-) diff --git a/ymmsl/v0_2/__init__.py b/ymmsl/v0_2/__init__.py index 31c274a..3646590 100644 --- a/ymmsl/v0_2/__init__.py +++ b/ymmsl/v0_2/__init__.py @@ -31,6 +31,7 @@ ConduitTimelineError, CyclicDependency, InconsistentTimelines, + ResolveTimelineException, TooManyReducerFilters, resolve_timelines, ) @@ -67,6 +68,7 @@ "ReferencePart", "resolve", "resolve_timelines", + "ResolveTimelineException", "ResourceRequirements", "Settings", "SettingType", diff --git a/ymmsl/v0_2/tests/test_timeline_resolver.py b/ymmsl/v0_2/tests/test_timeline_resolver.py index 40d5dd8..44a0b7c 100644 --- a/ymmsl/v0_2/tests/test_timeline_resolver.py +++ b/ymmsl/v0_2/tests/test_timeline_resolver.py @@ -61,6 +61,16 @@ def test_reducer(timelines_configuration: Configuration) -> None: assert model.components[Ref("second")].timeline == ROOT_TIMELINE +def test_only_reducer(timelines_configuration: Configuration) -> None: + model = timelines_configuration.models[Ref("reducer")] + # Remove the conduit first.final -> second.init1 and keep only the reduced conduit + del model.conduits[0] + assert model.conduits[0].filters == [ConduitFilter("last")] + resolve_timelines(model) + assert model.components[Ref("first")].timeline == ROOT_TIMELINE + assert model.components[Ref("second")].timeline == ROOT_TIMELINE + + def test_too_many_reducers(timelines_configuration: Configuration) -> None: model = timelines_configuration.models[Ref("reducer")] model.conduits[-1].filters.append(model.conduits[-1].filters[0]) diff --git a/ymmsl/v0_2/timeline_resolver.py b/ymmsl/v0_2/timeline_resolver.py index 62d99f7..68cfd66 100644 --- a/ymmsl/v0_2/timeline_resolver.py +++ b/ymmsl/v0_2/timeline_resolver.py @@ -15,8 +15,15 @@ def resolve_timelines(model: Model) -> None: """Determine timelines for each component and their O_I and S ports in this model. - An exception will be raised when the model timelines are not consistent. This - function updates the timeline attributes of the components and ports in the model. + This function updates the timeline attributes of the components and ports in the + model. Any of the following exceptions (which are a subclass of + :class:`ResolveTimelineException`) will be raised when the model timelines are not + consistent. See their description for more details: + + - :class:`CyclicDependency` + - :class:`TooManyReducerFilters` + - :class:`InconsistentTimelines` + - :class:`ConduitTimelineError` """ checker = TimelineChecker(model) checker.check_consistent() @@ -30,7 +37,11 @@ def resolve_timelines(model: Model) -> None: port.timeline = timeline.relative_to(component.timeline) -class CyclicDependency(RuntimeError): +class ResolveTimelineException(RuntimeError): + """Base class for exceptions raised while resolving timelines.""" + + +class CyclicDependency(ResolveTimelineException): """Error raised when some models form a dependency cycle. Dependency cycles occur when messages to an F_INIT port of a component depend in @@ -48,12 +59,18 @@ def __init__( f"Detected a dependency cycle in model '{model.name}'. The component " f"'{cycle[0].name}' has an F_INIT port that depends on data produced by " f"one of its own O_F or O_I ports: {cycle_str}. You may have an error " - "in the conduits or may need a different coupling schema." + "in the conduits or may need a different coupling scheme." ) -class TooManyReducerFilters(RuntimeError): - """Error raised when a conduit has too many reducer filters applied.""" +class TooManyReducerFilters(ResolveTimelineException): + """Error raised when a conduit has too many reducer filters applied. + + This error is raised when a reducer filter attempts to reduce a conduit that + already sends on the root timeline. :class:`InconsistentTimelines` or + :class:`ConduitTimelineError` may also be raised when too many reducer filters are + applied. + """ def __init__( self, model: Model, conduit: Conduit, sender_timeline: Timeline @@ -71,8 +88,13 @@ def __init__( super().__init__(msg) -class InconsistentTimelines(RuntimeError): - """Error raised when a component's F_INIT ports have inconsistent timelines.""" +class InconsistentTimelines(ResolveTimelineException): + """Error raised when a component's F_INIT ports have inconsistent timelines. + + When a component has multiple F_INIT ports, each port should receive on the same + timeline. Note that :class:`ConduitTimelineError` may be raised for conduits + connected to F_INIT ports when they have a repeater filter. + """ def __init__( self, @@ -97,7 +119,7 @@ def __init__( super().__init__(msg) -class ConduitTimelineError(RuntimeError): +class ConduitTimelineError(ResolveTimelineException): """Error raised for conduits that connect incompatible timelines.""" def __init__(