Implementing Pathwise Complexity

With Enterprise Hub and graph mode

Posted by steve on June 07, 2020
Pathwise Complexity | Implementing Enterprise Lineage | Data Input Validation

Background

Pathwise Complexity is one technique to measure complexity (and by inference quality) of business processes, but is dependent on rigour in the modelling process, and needs to be amended when a less mature approach has been adopted. In an earlier blog I demonstrated that pathwise complexity can drive the need to move beyond back-box process interactions and highlight the wider collaboration needed with counterparties.

A common pattern in long value-chains is to use intermediate event (like of-page connectors in a flow-chart) to extenuate a customer journey: in these scenarios the technique needs to be amended to meet site-specific conventions. Algorithmic precision can be traded for greater coverage of immature process maps – it is recommended to add start/end events to value-chains because intermediate events (in mature maps) highlight service interactions rather off-page links. The Enterprise Hub facilitates this with scripted workflows that can be evolved as the site becomes more mature. Included with the server are three variants of script to calculate the metric

Overview

The script is either run real-time (from update triggers) or overnight for an entire model. Pathwise Complexity is calculated and stored as metric objects for processes. From the spread of complexity score three bands derived and stored as indicator properties; then for ‘high’ complexity processes issues are created to feed into continuous improvement efforts

Implementation

The full implementation is 250 lines of functional F# code (* F# is used for economy of expression, and because it avoids the errors common with imperative languages). EA.Gen.Model provides the database binding to view a Sparx repository as a graph of interconnected nodes.
The core algorithm is a recursively searches through the connections on a diagram from each start-event accumulating a lists of paths each time an end-event is found. Rather than a simple recursive search with re-entry checking that would filter loops, the cycle function breaks the path into splices of routes delimited by the current node, then if there are any duplicates the path can be rejected.

        // start elements
        let starts = 
            diagRefs 
            |>  List.filter (fun i ->  i.Stereotype = "StartEvent" ) 

        // Find number of cycles of the same pattern
        let cycle (e : Element) (l : Element list) =
            if l = [] then 0
            else
                let id = e.Id 
                let state : (int list list * int list) = ([[id]],[]) 
                let splice = 
                    l
                    |>  List.filter (fun i ->  not (i.Stereotype = "StartEvent" || i.Stereotype = "EndEvent"))
                    |>  List.fold (fun (h,w) y ->  if y.Id = id then ([[y.Id] @ w] @ h, []) else (h, [y.Id] @ w)) state
                let t = List.rev (fst splice)
                let sets =
                    List.tail t @ [List.head t @ (snd splice)]
                List.map (fun i ->  List.fold (fun x y ->  if i = y then x + 1 else x) 0 sets) sets
                |>  List.fold (fun e y ->  (e + y - 1)) 0

        // seek recursively until EndEvents are found or cycle detected
        let rec findEnd (e : Element) (l : Element list) (paths : Element list list) = //: Element list list =
            let reenter = List.filter (fun (i :  Element) ->  i.Id = e.Id) l |>  List.length  
            if reenter >= 1 && ((cycle e l) > 0) then   // skip path if it loops
                paths
                [([e] @ l)] @ paths // add this path to the sets
            else
                e.StartConnectors 
                |> Seq.toArray
                |> Array.filter (fun c -> c.ConnectorType = "ControlFlow" && c.EndElementId.HasValue)
                |> Array.map (fun c -> c.EndElementId.Value)
                |> Array.filter (fun c -> refMap.ContainsKey (c))
                |> Array.map (fun i -> refMap.[i])                            // only connctions on the decomposition diagrams
                |> Array.fold (fun a v -> (findEnd v ([e] @ l) a)) paths

        starts 
        |> List.fold (fun a v -> (findEnd v [] a)) [] 
        |> Seq.map (fun i -> Seq.ofList i) 

A two line amendment to the starts list and EndEvent stereotype filter allows for paths between intermediate events to be included, while addition of && c.Stereotype = "SequenceFlow" to the connector type filter blocks traversal of message-flow paths Deployment The script is deployed to the enterprise hub by adding a scheduled job (for the whole-repository: load balanced by Quart.net in a cluster) or as a trigger on a connection for real-time service-side update.

      <job name="PathwiseComplexity" startup="true" startAt="01:00" interval="01:00" frequency="Daily" connections="ER">
        <trigger class="EA.Gen.Hub.Script.ScriptJob" assembly="EA.Gen.Hub.Script" description="validation" workflow="C:\Users\steve\source\repos\EA.Gen\EA.Gen.Hub.Script\PathwiseComplexity.fs"  />
      </job>

Pathwise Complexity is an example of the kind of complex data-intensive analysis/metrics that really need to run unattended on a server. Pathwise Complexity is an architype for the kind of quantitative enterprise architecture that is possible with {Sparx Enterprise Architect, EA.Gen.Model graph view of data, Enterprise Hub hosting environment. Full code will be familiar to any programmer familiar with {F#, OCaml, Haskell, Scala} – it is worth the effort to use functional languages to avoid unexpected side-effects.  

namespace EA.Gen.Hub.Script

open EA.Gen.Hub.Model
open EA.Gen.Model
open System.Linq
open System
open Quartz
open System.Threading.Tasks
open Serilog

(*
    Summary : Calculate the Pathwise complexity of the an element from the decomosition diagram of the object
*)
type PathwiseComplexity () =
    class
    let findPaths (element : Element) (diagrams : Diagram seq) : Element seq seq =
   
        // all references from diagrams
        let diagRefs = 
            let els (d : Diagram) = 
                d.Elements 
                |> Seq.map (fun i ->  i.Element)
                |> Seq.filter (fun i -> not ( i = null))
                |> Seq.toList
            diagrams
            |> Seq.fold (fun a y -> (els y) @ a) [] 
        
        // referenes as a dictionary for lookup
        let refMap = 
            diagRefs
            |>  List.map (fun i -> (i.Id, i))
            |>  Map.ofList

        // start elements
        let starts = 
            diagRefs 
            |>  List.filter (fun i ->  i.Stereotype = "StartEvent" ) 

        // Find number of cycles of the same pattern
        let cycle (e : Element) (l : Element list) =
            if l = [] then 0
            else
                let id = e.Id 
                let state : (int list list * int list) = ([[id]],[]) 
                let splice = 
                    l
                    |>  List.filter (fun i ->  not (i.Stereotype = "StartEvent" || i.Stereotype = "EndEvent"))
                    |>  List.fold (fun (h,w) y ->  if y.Id = id then ([[y.Id] @ w] @ h, []) else (h, [y.Id] @ w)) state
                let t = List.rev (fst splice)
                let sets =
                    List.tail t @ [List.head t @ (snd splice)]
                List.map (fun i ->  List.fold (fun x y ->  if i = y then x + 1 else x) 0 sets) sets
                |>  List.fold (fun e y ->  (e + y - 1)) 0

        // seek recursively until EndEvents are found or cycle detected
        let rec findEnd (e : Element) (l : Element list) (paths : Element list list) = //: Element list list =
            let reenter = List.filter (fun (i :  Element) ->  i.Id = e.Id) l |>  List.length  
            if reenter >= 1 && ((cycle e l) > 0) then   // skip path if it loops
                paths
            elif e.Stereotype = "EndEvent" then
                [([e] @ l)] @ paths // add this path to the sets
            else
                e.StartConnectors 
                |> Seq.toArray
                |> Array.filter (fun c -> c.ConnectorType = "ControlFlow" && c.EndElementId.HasValue)
                |> Array.map (fun c -> c.EndElementId.Value)
                |> Array.filter (fun c -> refMap.ContainsKey (c))
                |> Array.map (fun i -> refMap.[i])                            // only connctions on the decomposition diagrams
                |> Array.fold (fun a v -> (findEnd v ([e] @ l) a)) paths

        starts 
        |> List.fold (fun a v -> (findEnd v [] a)) [] 
        |> Seq.map (fun i -> Seq.ofList i)

    (* Summary : apply the complexity metric to database *)
    let metric (db : Sparx) (e : Element) values : unit = 
        
        let set = query {for r in e.Metrics do
                            where (r.MetricType = "Complexity")
                            select (r.Metric,r)}
                  |> Map.ofSeq

        let findOrCreate name = 
            match set.TryFind name with
            | Some m -> m
            | None   ->  let o = new ObjectMetric()
                         o.Metric <- name 
                         o.MetricType <- "Complexity"
                         e.Metrics.Add (o)
                         o
        let apply (p,m,t) = 
            (findOrCreate "Paths").EValue               <- Nullable<float>(float(p))
            (findOrCreate "Longest Path").EValue        <- Nullable<float>(float(m))
            (findOrCreate "Total Path Length").EValue   <- Nullable<float>(float(t))

        apply values

    let fromMetric (m : ObjectMetric) =
        match m with
        | p when m.Metric = "Paths"             -> (int(m.EValue.Value),0,0)
        | l when m.Metric = "Longest Path"      -> (0,int(l.EValue.Value),0)
        | t when m.Metric = "Total Path Length" -> (0,0,int(t.EValue.Value))
        | _                                     -> (0,0,0)

    let createProperty (id : int) (rag : string) = 
        let o = new ObjectProperty ()
        o.ElementId <- Nullable<int>(id)
        o.Property <- "Pathwise Complexity" 
        o.Value <- rag
        o

    let createIssue (id : int) (db : Sparx) = 
        let o = new ObjectProblem()
        o.ElementId <- id
        o.Problem <- "High Pathwise Complexity"
        o.ProblemType <- "Issue"
        o.DateReported <- new Nullable<DateTime> (DateTime.Now)
        o.Status <- "New"
        db.ObjectProblems.Add o |> ignore


    let createIssues (db : Sparx) = 
        query {for p in query {for p in db.ObjectProperties do 
                               where (p.Property = "Pathwise Complexity" && p.Value = "High" && p.ElementId.HasValue)
                               select p} do
               leftOuterJoin i in query {for p in db.ObjectProblems do 
                                         where (p.Problem = "High Pathwise Complexity")
                                         select p} on (p.ElementId.Value = i.ElementId) into g
               for r in g do
               select (p.ElementId.Value,r)}
        |> Seq.iter (fun (p,r) -> if r = null then createIssue p db)

    let measure (e : Element) (db : Sparx) = 

        let agregatetPaths e d = 
            let pathmap = findPaths e d
            let paths = 
                pathmap |> Seq.fold (fun a y -> a + 1) 0
            let totalmax =
                let length l = 
                    l |> Seq.fold (fun a y -> a + 1) 0
                pathmap 
                |> Seq.map (fun i -> length i) 
                |> Seq.fold (fun (m,t) y -> ((if y > m then y else m),(t + y))) (0,0) (*max,total*)
            (paths, (fst totalmax), (snd totalmax))
        
        Serilog.Log.Information ("Measuring {0} {1}", e.Name, e.GUID)

        let diagrams =
            let did = if not (e.PDATA1 = null) && (Seq.fold (fun a y -> if a then a else Char.IsDigit(y)) false e.PDATA1) then int e.PDATA1 else 0
            if did > 0 then
                query {for d in db.Diagrams do
                        where (d.Id = did)
                        select d}
                |> Seq.toArray
            elif e.ObjectType = "Package" then
                query {for p in db.Packages do
                       join d in db.Diagrams on (p.Id = d.PackageId.Value)
                       where (p.GUID = e.GUID)
                       select d}
                |> Seq.toArray
            else 
                [||]
        let aggregate = agregatetPaths e diagrams
        if not (aggregate = (0,0,0)) then 
            Serilog.Log.Information ("Measured {0} {1}", e.Name, aggregate)
            metric db e aggregate

    // Summary: Execute the measurement for the activity elements 
    let execute (elementClass : ElementClass) (repo: string) (guid : string ) : unit  =

        use db = new Sparx(repo)

        query {for e in db.Elements do
               where (e.GUID = guid && (e.ObjectType = "Activity" || e.ObjectType = "Package"))
               select e}
        |> Seq.iter (fun i -> measure i db |> ignore)

    // Summary: Execute the measurement for all packages that contain measures
    let executeAll (repo: string) : unit =

        use db = new Sparx(repo)
        try
            let childMetrics pid = 
                let max a b = if a > b then a else b
                query {for e in db.Elements do
                       join m in db.ObjectMetrics on (e.Id = m.ElementId)
                       where (e.PackageId.Value = pid)
                       select m}
                |> Seq.map (fun i -> fromMetric i)
                |> Seq.fold (fun (ap,am,at) (p,m,t) -> (ap + p,max am m, at + t)) (0,0,0)

            // measure all activities
            let elements = 
                query {for e in query {for e in db.Elements do
                                       where (e.ObjectType = "Activity" || e.ObjectType = "Package")
                                       select e} do
                       leftOuterJoin i in query {for p in db.ObjectProblems do 
                                                 where (p.Problem = "High Pathwise Complexity")
                                                 select p} on (e.Id = i.ElementId) into g
                       for r in g do
                       select (e,r)}
                |> Seq.toArray
            elements |> Array.iter (fun (e,r) -> if r = null then measure e db)

            let complexitySet =
                query {for m in db.ObjectMetrics do
                       where (m.Metric = "Paths" && m.MetricType = "Complexity")
                       select (m.ElementId, m.EValue.Value)}
                |> Seq.toArray
            let avg = (Array.fold (fun a (e,v) -> a + v ) (0.0) complexitySet) / float complexitySet.Length
            let stdev = sqrt ((Array.fold (fun a (e,v)-> a + (float v - avg) ** 2.0) 0.0 complexitySet) / float complexitySet.Length)
            let low = avg - stdev
            let high = avg + stdev

            let Rag v = 
                if (v > high) then 
                    "High"
                elif (v < low) then 
                    "Low"
                else
                    "Normal"

            let curTag =
                query {for t in db.ObjectProperties do
                       where (t.Property = "Pathwise Complexity" && t.ElementId.HasValue)
                       select (t.ElementId.Value,t)}
                |> Map.ofSeq
        
            complexitySet
            |> Array.filter (fun (e,v) -> curTag.ContainsKey(e))
            |> Array.iter (fun (e,v) -> curTag.[e].Value <- (Rag v))
        
            complexitySet
            |> Array.filter (fun (e,v) -> not (curTag.ContainsKey(e)))
            |> Array.map (fun (e,v) -> createProperty e (Rag v))
            |> (fun p -> db.ObjectProperties.AddRange (p))
            |> ignore

            createIssues db

            db.SaveChanges () |> ignore
        with 
            | :? Exception as e -> Serilog.Log.Error (e, "Pathwise Complexity error {0}", e.Message )
                

    (* Implement the trigger methods that would be called by the dynamic script job*)
    interface ITriggerScript with 
        member this.ExecuteTrigger ( elementClass: ElementClass, repo : string, guid : string) =
            execute elementClass repo guid

        member this.ExecuteAll ( repo : string, onlyModified : bool ) =
            executeAll repo 
end