facebookresearch / facebookresearch/fairo

Modularized droidlet: refactored design

Open
#337 12 comments 3 reactions 1 assignee Claimed by @soumith View on GitHub
Dominant language
Jupyter Notebook
Stars
929
Forks
123
PR merge metrics
No merged PRs in 30d

Description

This is a fairly involved rearchitecting of `droidlet`, so I'll provide as much context as possible.
I've discussed this with @kavyasrinet and @aszlam who both approved this direction.

## Motivation

There are several components of droidlet that are **useful by themselves**. Perception, memory, low-level control primitives and dialog models are components that are independently useful in many projects without having to build a monolithic agent.

> Making droidlet a set of independent components that compose well together can extract a lot more value for the user.

There are several projects in active learning, unsupervised learning, AR/VR, dialog assistants that can be formulated with only a subset of components available in droidlet. Yet, as of today, we need to create monolithic agents to do research on any of these topics.

The current state of the codebase doesn't encode that independence assumption. Almost every component of droidlet is dependent on another, and all of them are dependent on having an opaque access pattern via `self.agent`.

Here is how the dependency graph of droidlet looks like as of today:


drawing

This pattern is almost universally present.
Here are some examples:

```
./base_agent/nsp_dialogue_manager.py: ProgramNode.create(self.agent.memory, d)
./locobot/agent/perception/perception.py: "input": InputHandler(self.agent, read_from_camera=True),
./craftassist/agent/low_level_perception.py: BlockObjectNode.create(self.agent.memory, [(xyz, idm)])
```

The second issue with this monolithic state is that testing and debugging have become fairly hard, because of [action-at-a-distance](https://en.wikipedia.org/wiki/Action_at_a_distance_(computer_programming)) effects.
From our unit-testing, to our end-to-end testing via oncalls, we see the effects of interdependence.
@kavyasrinet mentions that it is common during minor refactors as well.

> Removing action-at-a-distance interactions almost universally outweighs the debugging and testing complexities of keeping it.

## The Proposal


drawing

The proposal is simple in nature, though the work involved is a lot of both mechanical refactoring and redesigning several APIs to be nicer in this new world.

`perception`, `memory` and `dialog` are Independent components that have no dependencies to other components in the project.

Here is the folder structure for them in the root directory.

```
perception/
memory/
dialog/
lowlevel/
minecraft/
locobot/
franka/
hellorobot/
habitat/
```

They will install as `droidlet.perception`, `droidlet.memory`, `droidlet.dialog`, ...

`interpreter` will depend on memory. We will completely clean out the notion of a `controller` which was often confused with/interchangably used with `interpreter`, and instead just have `interpreter`.

```
interpreter/
base/ # level 0
robot/ # level 1
minecraft/ # level 1
franka/ # level 2
hello/ # level 2
```

The `interpreter` tests need `memory` fills, i.e. entries in memory to test functionality correctly. So, interpreter unit tests will need some small `agents` (not in the old traditional sense with a monolithic class structure, but just a quick function that creates memory entries).

Lastly, there will be an `agents` folder that has one file per agent of a certain characteristic.

```
agents/
robot_assistant.py
minecraft_assistant.py
active_learner.py
object_grasping.py
```

The agents will be a single file each, and can be "unstructured" or "structured". More on this later in this proposal.

### Memory schema and Interpreter Dictionary

There are three monolithic memory schemas that exist right now:

- droidlet schema - Level0
- +locobot schema - Level1
- +minecraft schema - Level1

Either the minecraft or locobot Level-1 schemas can be composed upon the droidlet base schema which we call a Level0 schema.

While we could try to over-modularize the memory-schemas into "perception schema", "arm schema", etc., we aren't aiming to do so in the current proposal.

The end state we'd like to see in this current refactor is to keep this composable leveling pattern, and if needed, formalize a "leveling" or "composablity", so that one knows either from documentation or via a runtime error which schemas are composable on top of others. For example, it should be encoded that `minecraft` depends on / composes on top of the `droidlet` schema.

Similarly, we have dictionaries in the canonical interpreter representation, where some base words are universal, but some specific words are related to capabilities of a given environment or agent.

For example, `move` is a common word in all agents (but doesn't have to be), but `grasp` or `pinch` might be related to specific agent capabilities. Some environments can have new capabilities, for example `swim`.

We will have a base interpreter dictionary, and then have subsequent composable levels. This is again not too far from the status quo, so I won't detail it here.
There is no plan to break the vocabulary down into further hierarchical composable vocabularies than the current scheme we have.

## Pseudo-code

### Individual Components

Once we get rid of `self.agent` and `self.memory` in almost all the other components, this means two things:

1. These components no longer need to carry state, and hence can be purely functional
2. The exchange of state is explicitly what the `agent` enables

For example, using the object detector would simply be:

```python
from droidlet.perception import ObjectDetector

object_detector = ObjectDetector(classes="coco") # loads the pre-trained detector weights, etc.
objects = object_detector(input_image)
```

To use something like the object tracker that is stateful, one does something like this:

```python
from droidlet.perception import ObjectTracker

tracker = ObjectTracker()
all_objects = []

objects1 = object_detector(input_image_1)
unique_objects1, all_objects = tracker(objects, all_objects)

objects2 = object_detector(input_image_2)
unique_objects2, all_objects = tracker(objects, all_objects)
```

Similarly, dialogue manager won't store the previous conversational state within itself, and will be purely functional:

```python
from droidlet.dialog import DialogModel
# here, DialogModel is the one that includes pre-processing and post-processing

model = DialogModel(vocab=["droidlet", "locobot"])

previous_state = []
input = "go to the chair"

dialog_object = model(input)

previous_state+= [input, dialog_object]
```

### Agent code

#### Simple functional agent

This results in a full-scale agent doing mostly state-passing and event-loop processing.

A simple agent would be a giant ~200 line function that creates all necessary components of the agent and defines how they interact with each other.

```python
from droidlet.perception import ObjectDetector, ObjectTracker, HumanPose
from droidlet.memory import Memory
from droidlet.lowlevel import Locobot
from droidlet.interpreter.robot import Interpreter
from droidlet.dialog import DialogModel
from droidlet.utils import BackgroundTask
from dldashboard.utils import get_text

def agent():
# Initialize components

# perception
detector = ObjectDetector(...)
tracker = ObjectTracker(...)
human_pose = HumanPose(...)

def perceive(rgbd, prev_state):
objects = detector(rgbd)
unique_objects, next_state = tracker(objects, prev_state)
humans = human_pose(rgbd)
return unique_objects, humans, next_state
perceive_background = BackgroundTask(perceive)

locobot = Locobot(ip="IP_ADDRESS")
mem = Memory(schemas=["droidlet.schema", "locobot.schema"])
vocab=["droidlet", "locobot"]
dialog_model = DialogModel(vocab=vocab)
interpreter = Interpreter(vocab=vocab, memory=mem)

while True:
# start the task loop
try:
text = get_text()
dobj = dialog_model(text)
mem.push("dialog_state", [text, dobj])
interpreter.push(dobj)
except TextNotAvailableException:
pass

rgbd = locobot.get_rgb_depth()​
prev_objects = memory.get_all("perception_objects")
perceive_background.process(rgbd, prev_objects)
try:
unique_objects, humans, next_state, perceive_background.dequeue()
mem.push("perception_objects", unique_objects)
mem.push("perception_humans", humans)
except NotReadyException:
pass

actions = interpreter.get_actions()
for action in actions:
locobot.execute(action)

```

The only state that is being pushed or pulled outside of this code block is when the interpreter directly interacts with memory.
All other state is explicitly carried through the `agent` implementation here.

This might be slightly more verbose, but it does benefit from two productivity boosters:
1. The code is much easier to read for people not familiar with the codebase, or even generally
2. Debugging becomes much easier because you are really only just debugging this function, assuming that all the imported components are fairly well-tested

#### Pub-Sub agent

I haven't fully fleshed out the design for this style of agent, but, as an opt-in, you can use this style when dealing with complex projects.
In some future distribution, we could even replace a trivial but not-as-performant pub-sub implementation with a much tighter C++-optimized one.

Final design here still TBD.

```python
from droidlet.perception import ObjectDetector, ObjectTracker, HumanPose
from droidlet.memory import Memory
from droidlet.lowlevel import Locobot
from droidlet.interpreter.robot import Interpreter
from droidlet.dialog import DialogModel
from droidlet.utils import BackgroundTask
from dldashboard.utils import get_text, save_to_mem

def agent():
# Initialize components

# perception
detector = ObjectDetector(...)
tracker = ObjectTracker(...)
human_pose = HumanPose(...)

def perceive(rgbd, prev_state):
objects = detector(rgbd)
unique_objects, next_state = tracker(objects, prev_state)
humans = human_pose(rgbd)
return unique_objects, humans, next_state
perceive_background = BackgroundTask(perceive)

locobot = Locobot(ip="IP_ADDRESS")
mem = Memory(schemas=["droidlet.schema", "locobot.schema"])
vocab=["droidlet", "locobot"]
dialog_model = DialogModel(vocab=vocab)
interpreter = Interpreter(vocab=vocab, memory=mem)

@dlevent.publish("rgb")
def rgb_pub():
return locobot.get_rgb_depth()

dlevent.publish("text", get_text())
dlevent.trigger("rgb", perceive_background, publish="detections")

dlevent.trigger(["rgb", "detections", "text", "dialog"], save_to_mem)

dlevent.trigger("dialog", lambda ir: interpreter.push(ir))

@dlevent.subscribe("interpreter_get_action")
def take_action(actions):
for action in actions:
locobot.execute(action)
```

This pub-sub model doesn't make a ton of sense in this toy example, but it could start making sense in fairly complex projects where you do need to break many parts of this logic into hundreds of lines or even separate files.

## Breakdown of tasks

I haven't fully mapped the order in which PRs will appear, but I will keep them incremental and hopefully easy to review.

Contributor guide

Open the contributing guide

Assessment

This issue has not been assessed yet.

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.