I create a lot of data processing pipelines using mass spectrometry data, where data from the instrument gets cleaned up, transformed, scaled, inspected, and finally analyzed. I tend to use a recursive type definition for this -- here's a heavily simplified example:
data Dataset = Initial { x::(Vector Double), y::(Vector Double) name::String}
| Cleaned { x::(Vector Double), y::(Vector Double) name::String}
| Transformed { x::(Vector Double), y::(Vector Double) name::String}
Then a typical pipeline will just be a chain of functions that starts with a Dataset creator, and then continue with functions that consume something of type Dataset, and produce something of type Dataset:
createDataset :: Vector Double -> Vector Double -> String -> Dataset
createDataset x y name = Initial x y name
removeOutliers :: Dataset -> Dataset
removeOutliers (Initial x y n) = let
(new_x, new_y) = outlierRemovalFunction x y
in Cleaned new_x new_y (n ++"_outliersRemoved")
(Cleaned x y n) = error "Already been cleaned"
(Scaled x y n) = error "Scaled data should have already been cleaned"
(Transformed x y n) = error "Transformed data should have already been cleaned"
logTransform :: Dataset -> Dataset
logTransform (Initial x y n) = error "Need to clean first"
(Cleaned x y n) = let
(new_x, new_y) = logTransformFunction x y
in Transformed new_x new_y (n ++ "_logTransformed)
So this makes sure that the processing steps in the pipeline happen in the correct order, and you can create whole pipelines using composition
(logTransform . removeOutliers . createDataset) init_y init_y "ourData"
But this approach seems extremely limited for a couple of reasons. The first reason is that incorrectness is detected via pattern matching on constructors, so additions and changes to the pipeline will require changes everywhere in the pattern matching. Imagining a more complicated example with several cleaning and several transform steps -- basically every possible combination will need its own unique constructor, and all the pattern matching will have to be non-exhaustive, or repeated absolutely everywhere.
The second reason this seems limited is that an incorrectly constructed pipeline is only detected by failures at runtime. I've sequenced all the processing steps, so at every point in the pipeline I know exactly what has happened to the data. The type system should be able to keep me from putting the steps together incorrectly in the first place, and using a function expecting cleaned data on a non-cleaned input should be detectable at compile time.
I have thought about having separate types for each of the stages in the pipeline, and then having the "dataset" interface implemented as a type class, something like:
class Dataset a where
x :: a -> Vector Double
y :: a -> Vector Double
name :: a -> String
data Initial = Initial x y name
instance Dataset Initial where ...
data Cleaned a = Cleaned a
instance Dataset Cleaned where ...
data Transformed a = Transformed a
instance Dataset Transformed where ...
then you can do things (I think...) like:
removeOutliers :: (Dataset a) => a -> Cleaned a
removeOutliers = ...
logTransform :: (Dataset a) => Cleaned a -> Transformed Cleaned a
logTransform = ...
I believe that this approach fixes issue number 1 above: We can now detect pipeline incorrectness at compile time, and we no longer are stuck with having to have all those different constructors to describe the processing steps.
However, it seems that I've just moved the problem "one level up". I'm now dealing with type variables and all these nested types. Instead of a needing a Dataset constructor for every possible combination of pipeline steps, I now need to create a Dataset instance for every combination of types!
What I really want is a way for a type in the processing pipeline to be both very specific or very general in their constraints. I'd like to use types/constraints that detail the order in which specific processing steps were applied, but I'd also like for a type/constraint to be able to convey something more general -- i.e. "In addition to other unimportant steps, outlier removal has been done". So basically the type of things that have had outliers removed.
Conveying ordering information would be an ultra-bonus -- "In addition to other unimportant steps outlier removal happened and at some point later on a log transformation happened". The type of things that have had outliers removed before they were log transformed (and not necessarily immediately before).
Is this sort of thing possible using Haskell's type system?