Serving delicous data with the Pathom Logic Engine

An exploration of the Pathom3 Clojure library we used to design our new Price Score system.

In a recent update to the Beam platform we added support for several new finance options in our Procure service which will now allow for various other contract offers to be made by Solar Retailers.

Screenshot 2023-04-17 at 12.17.27 am


One of the most difficult parts of implementing these new options is avoiding our Price Score logic becoming too complex and difficult to maintain.

In a previous Blog we mentioned the Pathom3 Clojure library as one of the technologies we are adopting at Beam and we used it extensively in the development of these new features. It helped us design a system that could handle the new contract types in a generic way.

This time our blog is more of a technical knowledge share in the form of a guided reading/ tutorial of our learnings working with the Pathom library.

Having the fantastic Pathom3 docs alongside with the reading would be recommended. Without further ado, we'll be introducing Pathom and building up an example to calculate a scoring for dishes in a restaurant menu where the lowest calories score the best.


So, what is Pathom? From the Github page, Pathom3 is defined as a:

"Logic engine for attribute processing for Clojure and Clojurescript."

In another way, we provide the train tracks, starting point and desired destination and Pathom assembles the train tracks in a way that allows the train to go to the desired destination from the starting point.


We're starting with a minimal pathom example with 2 resolvers, registering the resolvers in a pathom environment and an example of processing a query against the environment.

First, we'll look at 2 equivalent Pathom resolvers, one with a meta-map to allow more control of the resolver's input and output keys. (The empty `[]` means that the resolver is considered global as it doesn't require any dependencies)

Screenshot 2023-04-17 at 12.56.22 am

Screenshot 2023-04-17 at 12.56.34 am

Next we'll look at the environment. (Quite literally adding the resolvers into a registry) Screenshot 2023-04-17 at 12.57.00 am

Finally, we ask for `:answer-to-everything` which can be obtained from either resolvers without a starting point and we'd get 42. (empty starting point represented by `{}`) 

Screenshot 2023-04-17 at 12.57.29 am


Now that we've seen a pathom resolver, environment and how to query against the environment. We'll bring in a dynamic resolver which can help us create linking between data to be resolved by the Pathom planner. 

Screenshot 2023-04-17 at 1.12.27 am

This resolver's output is the same as `:answer-to-everything` which will be 42.

However, things might be interesting when we look at providing a starting point to the query. In the following example, in a world where `:answer-to-everything` is 50, what is the `:answer-with-dependency`? 

Screenshot 2023-04-17 at 1.15.19 am

Since we have the `:answer-to-everything`, Pathom resolves the `:answer-with-dependency` to be 50 and not 42. 


Now that we've seen how resolvers with dependencies work as well as pass in a starting point to a pathom query, there are certain cases where we'd want the output keys to be a generic name. In our example, we have 3 resolvers to calculate `:calories` for Protein, Carbohydrates and Fats and we want them to all be a generic `:calories`. We can turn to Pathom's aliases. (1g of protein = 4 calories, 1g of carbs = 4 calories & 1g of fats = 9 calories)

Screenshot 2023-04-17 at 1.24.09 am

Screenshot 2023-04-17 at 1.25.39 am

We'd add these aliases to the registry the same as before.

Screenshot 2023-04-17 at 1.26.22 am

We can now query the environment with a starting point of 10g of fats, expecting both calories to return as 90. (as our example assumes 1g of fats = 9 calories)

Screenshot 2023-04-17 at 1.26.34 am


Unfortunately, the Pathom environment would result in unexpected behaviour in the following example, where fats and protein are both in the same level of data. (:calories can be either 40 or 90)

Screenshot 2023-04-17 at 1.30.26 am

We've been using "nested entity joins" with EQL is what the query is. (`[:calories]`) 

The image below reads, this ingredient entity contains 3 attributes: protein, carbohydrates and fats, each with a :calories. This allows us to have :calories key to be nested within an attribute via an EQL join.

Screenshot 2023-04-17 at 1.38.44 am

From here, we're able to calculate the total calories for this ingredient, by creating a resolver that takes in an ingredient entity's protein, carbohydrates and fats attribute and returns a `:ingredient/calories` attribute.

Screenshot 2023-04-17 at 1.43.40 am

In the case where an ingredient that provides 10g of protein, 5g of carbohydrates and 4g of fats, it is equivalent to 96 calories.

Screenshot 2023-04-17 at 1.43.51 am


Now that we have the ability to calculate a single ingredient's calories, we can now get the calories for, let's go with a fish and chip dish, with 2 pieces of battered fish and medium serving of chips! To which the answer is 107 calories for each fish and 254 calories for the chips. 

Screenshot 2023-04-17 at 1.56.16 am


What we would want after having calorie values of each ingredient is the sum of the calories! and that's done by... simply creating another resolver! 

Screenshot 2023-04-17 at 2.00.31 am 

Similar to before, once we're able to calculate one dish's calorie value, we're able to get multiple dishes' calorie counts. In the following example, we have the same fish and chips dish and a pepperoni pizza where the total calories of each dish is 468 calories and 970.21 calories respectively, the cheese and dough provides so many calories!

Screenshot 2023-04-17 at 2.01.20 am


Now that we're able to get the calories for each dish, we're getting close to being able to score them relative to each other! The next step we'd take is to be able to find out the minimum calories between the dishes. Which will be 468 calories from the Fish and Chips.

Screenshot 2023-04-17 at 2.08.18 am


So far, we've been able to continuously go down through a structured data structure to calculate attributes relating to a single dish. However, each dish will need to know it's calorie count relative to the minimum calorie count across other dishes in the same menu to be able to get a score. 

This is where our "nested entity joins" from earlier play a crucial role in allowing us to reuse existing resolvers. The following screenshot shows that we've included a way for the dish to have a reference to the menu it belongs to. (In the form of :dish/menu)

Screenshot 2023-04-17 at 2.17.01 am

From here, we simply introduce another resolver to fetch the menu attributes from it's id (this may be a unique ID in a database row used for the lookup).

Screenshot 2023-04-17 at 2.19.58 am

We can now shorten our starting state to be just the id of the menu and let pathom handle the in-betweens to get that menu's minimum calories. 

Screenshot 2023-04-17 at 2.23.11 am

We're able to get the menu's minimum calories from each dish and thus allow each dish to know the minimum calories across the menu.

Screenshot 2023-04-17 at 2.23.22 am

Finally, we're able to calculate each dish's score, our goal for this example.

Like a broken record, it's just another resolver! 

Screenshot 2023-04-17 at 2.29.39 am

And with that, our work here is done. Querying for scores of each dish from the menu earlier shows that Fish and Chips (with the lowest score) has a score of 100% and Pepperoni Pizza has a score of 48.237%. Also means that Pepperoni Pizza has more than twice the amount of calories as Fish and Chips. 


In summary, in our example, we've created 9 resolvers, each with a single goal and we've ended up with a feature that is able to handle an arbitrary number of menus, each menu with a potentially arbitrary number of dishes, and each dish with again, potentially arbitrary number of ingredients and we'd still be able to get the respective total calories in each level (ingredient, dish, menu)! We're very excited with developing what's to come soon and hope that this article has been helpful or even, an interesting read!


source code available here

Similar posts