Refactoring tasks

Problem Description

We have customers interested in combining the behaviors of multiple tasks. For example, as part of a single task, users may need to edit an attribute resource as well as run an operation. The task’s style may make both the attribute editor panel and the operation parameter-editor panels visible, but with the current design a new subclass of smtk::task::Task would be required to properly compute the task state (i.e., to indicate when both the operation has succeeded and the attributes have been validated).

Because the logic for state computation is in different subclasses, there is no way to re-use this logic in the new task; code from both smtk::task::FillOutAttributes and smtk::task::SubmitOperation would have to be cribbed together by the new task subclass (call it smtk::task::CompositeTask for the purposes of this discussion). We wish to avoid this duplication.

Potential Solutions

All of the solutions we consider would split subclasses of smtk::task::Task to separate the logic for linting/validation/state-computation into a separate class. There are many ways to accomplish this, but we will consider two that are representative. This “linter” or “validator” class may be

  • multiply inherited by task subclasses in order to re-use the configuration and validation logic; or
  • instantiated and owned by instances of the base smtk::task::Task class (i.e., there would be no more need to subclass tasks).

We’ll call the first option “multiple inheritance” and the second option “no inheritance” as we consider the implications of these designs in regards to the issues below. Before we do that, let’s sketch what each option might look like.

The multiple-inheritance option might look like

class CompositeTask
  : public smtk::task::Task
  , public AttributeValidator
  , public OperationValidator
{
public:
  /// Pass portions of configuration to each inherited validator.
  void configure(const Configuration& config) override;
};

while the no-inheritance option might add internal storage to the base task class like so:

class Task : public smtk::resource::Component
{
protected:
  /// Every task has run-time-configurable validators.
  std::vector<std::unique_ptr<ValidatorBase>> m_validators;
  /// By default, the task will call this function when any validator's
  /// internal state changes. If needed, we can hold a lambda that
  /// will serve as a replacement.
  static Static combineValidatorStates(
    const std::vector<std::unique_ptr<ValidatorBase>>&);
};

Issues to consider

We can summarize the issues and differences between the designs in this table. Some more detailed notes then follow in the sections below.

Issue Multiple Intheritance No Inheritance
Task State Task subclasses would use the public API that each of its inherited validators provides to call Task::internalStateChanged() as needed. Validators call a method on the task when their internal state changes and the task calls combineValidatorStates()
Ports Task subclasses would be responsible for passing notifications to/from their validators as they receive updates about port-data changing or requests for data from a port. The base task class would iterate over validators and notify each one of changes or request port-data from each. For now, only one validator would produce data for any given output port. In the future, data from multiple validators may be combined.
Children If a task subclass needs children, it would be responsible for re-implementing the API provided by the base Task class. The base task class would now provide a vector of child tasks. An empty vector would be the default. See notes below.
Style Minimal changes would be required since dynamic casting to a validator provides equivalent functionality. Style responders would need to iterate the task’s validators to fetch information needed.
Creation The existing task factory would be sufficient. The task-manager would need an additional factory to construct validators.

Ports

In the “multiple inheritance” design, the relationship between task ports and inherited validators must be manually managed by the author of the task subclass.

In the “no inheritance” design, we would require each validator’s configuration to explicitly mention the ports from which it should draw configuration and to which it should broadcast information. Thus the base validator class would need to have the same virtual methods for ports that tasks currently provide; tasks just forward these calls to each iterator. The base class for all validators could also provide an interface allowing the task to identify which ports have data consumed/produced by it, but this would not be necessary in an initial implementation.

Children

There are additional considerations for the “no inheritance” design. GroupTask would no longer be required since all tasks are allowed to have child tasks. All child tasks would be required to be completable before the parent task becomes completable. However, we may relax this requirement in the presence of a validator that claims control of child tasks.

For example, a parent “Define Simulation Domain” task might have a child “Load Geometry” task and a child “Create Geometry” task. Normally, both would be required to be completed, but the parent task might provide a ChildValidator that only requires one to be complete in order to proceed.

Style

Currently, the style for the attribute-editor panel and operations parameter-editor panels look like this:

{
  "styles": {
    "operation_style_tag": {
      "operation-panel": {
        "display": true,
        "hide-items": ["/item1", "/item5" ]
      }
    },
    "attribute_style_tag": {
      "attribute-panel": {
        "attribute-editor": "ViewName"
      }
    }
  }
}

In each case above, the style is applied by the panels themselves; they monitor the active task and when it changes, they apply any relevant style from the task-manager. However, in order to do this, they dynamically cast the active task to a fixed type (SubmitOperation or FillOutAttributes) to obtain information needed (the operation or attribute resource containing the “ViewName” view configuration).

In the “no inheritance” design, panels would need to iterate over validators owned by the task. In the future, the style might be extended to call out specific validators (either by index since they are held in a vector or by name) that data should be fetched from. For example,

{
  "styles": {
    "operation_style_tag": {
      "operation-panel": {
        "validator": "MeshRunner",
        "display": true,
        "hide-items": ["/item1", "/item5" ]
      }
    },
    "attribute_style_tag": {
      "attribute-panel": {
        "tabs": [
          { "validator": "Thermal",
            "view-name": "ThermalBCs" },
          { "validator": "Flow",
            "view-name": "FluidBCs" }
        ]
      }
    }
  }
}

adds validator names and, in the case of the attribute panel, provides a way to generate a toplevel view configuration that is a tabbed group-view; each tab of the group-view would be a view configuration from that validator’s attribute resource.

In the “multiple inheritance” design, the panels could continue to do this but casting to validator classes instead. While the same validator class may be inherited multiple times, this cannot happen in a meaningful way and even if it is done, the style-responder would not be able to distinguish from among them.

Proposed Solution

We propose using the “No Inheritance” model described above because it removes the need for subclassing tasks. All validators would inherit a common base that requires/provides the following:

class Validator
{
public:
  using State = smtk::task::State;
  virtual State internalState();
  virtual void configure(const Configuration& config) override;
  virtual std::shared_ptr<PortData> portData(const Port* port) const;
  virtual void portDataUpdated(const Port* port) { (void)port; }

protected:
  // Validators must have a parent task in order to notify it
  // of state changes.
  smtk::task::Task* m_parent;
  // Notify the task that it needs to recompute state because this
  // validator's state has changed.
  void stateChanged(State prev, State next);
};

Subclasses would include:

  • AttributeValidator – from FillOutAttributes
  • OperationValidator – from SubmitOperation

FYI @rohith @Aaron @justin.2.wilson @Bob_Obara @johnt

After more discussion, we have decided

  1. We will proceed with the “no inheritance” design pattern as proposed. This does not mean you cannot subclass smtk::task::Task, but in most cases you should not need to.
  2. The class name for Validator in the proposal above will be changed. Instead of Validator or Linter, tasks will be composed of Agent instances that (a) validate the state of modeling resources relevant to a workflow task, (b) process input data to modify their configuration, and (c) produce output data that may be passed downstream. We chose the name Agent because each instance held by a task does more than validate the consistency and complete-ness of resource.
  3. Agents will, as part of their configuration, reference a task’s ports that they monitor or broadcast (rather than the task holding a map from port to agent).
  4. For now a task will have a fixed method for combining agent states into the task’s final state. We are open to accepting a lambda in the future that will combine agent states plus any task dependencies.
  5. When a task is asked to produce data for a port, it will iterate over its agents and ask each for port data. If more than one agent produces port data, it will be merge()-ed with any previous data.
  6. Because there may be a need for agents to modulate the port-data they transmit based on the overall state of the task, we will provide agents with a way to track changes to the overall task state.
class Agent
{
public:
  using State = smtk::task::State;
  virtual State internalState();
  virtual void configure(const Configuration& config) override;
  virtual std::shared_ptr<PortData> portData(const Port* port) const;
  virtual void portDataUpdated(const Port* port) { (void)port; }

protected:
  // Agents must have a parent task in order to notify it
  // of state changes.
  smtk::task::Task* m_parent;
  // Notify the task that it needs to recompute state because this
  // agent's state has changed.
  void stateChanged(State prev, State next);
  // Receive notification the parent Task's state has changed.
  virtual void taskStateChanged(State next) { }
};

The implementation of task agents is now in SMTK MR 3058 awaiting final review. We’ve written 3 agents:

  1. GatherObjectsAgent – broadcast a pre-configured (or programmatically-configured) set of objects on a parent-task’s output port.
  2. FillOutAttributesAgent – check that attributes a user may edit are valid (i.e., all items have proper values and associations are allowable).
  3. SubmitOperationAgent – ensure a user has run an operation at least once. Note that unlike the SubmitOperation task (with no agents), the agent functions as both the ConfigureOperation adaptor and the SubmitOperation task; it can configure operation parameters with values taken from input ports. Also, it broadcasts resources mentioned in the most recent operation’s run on its output port.

There are more agents we think will be useful in the future:

  • SelectorAgent – choose a set of children tasks whose state should be combined to obtain this task’s state (ignoring all other children tasks).
  • AttributeAssociationAgent – verify that all objects of a given type are associated to attributes with a particular definition (and potentially vice-versa: all attributes with a matching definition have at least one association).
  • PortForwardingAgent – combine port-data from a task’s input ports and present it on output ports. This may be its own agent or could also be implemented via a modified GatherObjectsAgent.