studio icon indicating copy to clipboard operation
studio copied to clipboard

[STU 192] - RFC - (Reactive) Blocks Developer Experience

Open TheBigSasha opened this issue 1 year ago • 7 comments

Mirror of STU-192 on Linear

Question: How do we make it easy and powerful to write flojoy nodes?

Subquestion: What is the purpose of a flojoy node?

Subquestion: What control vs abstraction trade offs do we want to make?

Principles:

  • A node should be develop-able with minimal boilerplate
  • A node's full execution 'life cycle' should be controllable and accessible
  • Publishing node output should be doable via returning from the function (simple lambda nodes)
  • Node publishing should also be programatically controllable (emitter nodes, hardware nodes, etc.)
  • Memoization should be manually controllable for nodes
  • Node source should be compactly serializable and loadable (for node "app store")
  • Existing nodes should be trivially "plug and play" to the new system with either an HOF or a simple refactor

Note regarding pseudocode style: The pseudocode in this document resembles Java or TypeScript. A bit of mixed syntax, picking language features from both as convenient for brevity and specificity; the real python code will probably look quite different. The use of generics isn't completely valid but should get the point across about which types go where. Feel free to reach out to me (@TheBigSasha ) if you have any questions.

Design Proposal

  • Base node class defining the interface from the execution perspective
abstract class FJBlock<In, Out> {
  /**
    return the current name, this can be used for status IE download (idle), then download (50%), then download (done)
  */
  public default String name();
  /**
     return the current description, which can change based on the context of the node state or settings
   */
  public default String description();
  /**
    **NORTH STAR FEATURE**
    return the input names and types of the class, by default use reflection to return the In type, but when In is an aribtrary object, allow fully custom outputs.
    This is useful for highly generic nodes like ROS pub sub. Beats using a JSON input / output
  */
  public default Pair<String, String>[] getFlowchartInputs();
  /**
    the function called by the flowchart graph when dependent nodes recieve all inptus
  */
  public abstract void on_next(In input);
  /**
    an unsafe internal function to get the subject (for wiring into the flowchart)
  */
  public default Subject ___get_subject___();
  /**
     a safe user-oriented abstraction of the observable to publish data to the flowchart
  */
  protected default void publish(Out out);
  /**
    a safe user-oriented hook for memoization (think useMemo or useEffect in React)
   */
  protected default T memoize<T>(fn: () => T, deps: Array<Any>);
}
  • Wrapper hides the class and it's details for basic "lambda block"
  • Lambda block factory injects hooks and wires up the FJBlock class in the most common way
  • Lambda block can also be the place were we use the docstring parser to get name, desc, flowChart inputs so that it can be a simple refactor of all existing nodes
  • A lambda block makes up all "pipeline" blocks, visualization blocks, etc. Any block which do not have internal state
FJBlock<In, Out> make_lambda_block(run: (input: In, hooks: Function[]) => Out) {
  // we pass in class method "memoize" as a hook to the lambda because it lets us have some more built in functions (hooks) to manage the block without class access
  return new FJBlock<In, Out>() {
    //...the rest of the implenentation of abstract methods
    on_next(input) {
       this.publish(run(input, [this.memoize]));
    }
  }
}
  • Class blocks are used for nodes which manage a state, including all hardware, download state, etc. Nodes
  • Downloads can later be abstracted in an make_async_lambda_block but that's not needed for initial proposal
interface NAxisState {
  floatState: float[];
  discreteState: bool[];
  getDefinitions: Pair<int, string>[];
}

class NAxisBlock<{controllerID: int}, NAxisState>{
   this.controller = null;

  void onNext(input) {
    memoize<void>(() => {
      if (this.controller != null){
        this.controller.destruct();
      }
      this.controller = getGameController(input.id);
      
      this.controller.onInput((input: NAxisState) => {
         this.publish(input);
       }
    }, [input.controllerId])
  }
}
  • Legacy blocks should be easy to migrate. If we can't use lambda blocks, can we come up with a class block that fully encompasses the function of retro style blocks, IE generates a class block given the function and python doc for a classic flojoy block
FJBlock<In, Out> import_legacy_block(block: Function, docstring: Docstring){
    // Write a function to turn a legacy block into an FJBlock instance. Use the docstring for the parameters, title, etc. And use the on_next for running.
}

Function Blocks, Hooks

A block can contain hooks for effects/memoization, and internal state which abstract away the RX subjects.

Publish

The publish or "state" hook works just like ReactJS useState — upon calls to publish (setState), changes are propagated. Otherwise, changes to state do not propagate in the reactive compute graph. This can be used for hardware support, like so:

const oddsOnly =  (next, hooks) => { 
    if (next % 2 != 0){
     hooks.publish(next);
   }
    return;
}

Instead of returning nulls, this function block filters out evens and propagates only odds by using the publish hook.

Memoize / Effect

The memoize hook is particularly useful to ML or other long compute tasks. It takes a lambda and a list of parameter keys, and it runs the lambda once, and then afterwards only when one of the parameter keys changes. Think useMemo and useEffect in React terms. There is no distinction here, since React distinguishes these based on time of run, primarily for DOM access, which is not an issue for us.

TheBigSasha avatar Dec 04 '23 05:12 TheBigSasha