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.
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)
Next we'll look at the environment. (Quite literally adding the resolvers into a registry)
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 `{}`)
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.
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`?
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)
We'd add these aliases to the registry the same as before.
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)
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)
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.
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.
In the case where an ingredient that provides 10g of protein, 5g of carbohydrates and 4g of fats, it is equivalent to 96 calories.
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.
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!
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!
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.
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)
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).
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.
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.
Finally, we're able to calculate each dish's score, our goal for this example.
Like a broken record, it's just another resolver!
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