2

I'm dealing with a state machine that is currently traversed via Dijkstra's algorithm. However, now I need to enhance that state machine to be "smarter" in how it figures out routes to account for some side-effects. Basically some paths are not traversable if certain requirements aren't met, even if you're in the correct starting state for that path. These requirements can be satisfied by traversing other paths first. A simplified example of what I'm trying to address is traveling between cities:

  • You can travel domestically without your passport (just a basic ID) (i.e. Philly -> NYC)
  • As soon as you need to travel internationally, you need your passport (NYC -> Paris)
  • If you already have your passport, you can travel internationally (NYC -> Paris)
  • If you don't, you need to travel home first to take it (NYC -> Philly -> NYC -> Paris)

An alternative example (that I'm actually dealing with) is website states and the concept of being logged in and logged out).

There are 2 approaches I'm thinking of:

  • Composing states (i.e. having passport is itself a secondary state that can be combined with "location" states), this sounds like it would add a whole other dimension to my state machine and I'm not sure whether it would make the algorithm a mess.
  • Conditional paths that are only available if certain property is set while being in a state (a somewhat Bayesian approach), this would effectively make my states "impure", where transition taken would depend on internal state properties, so I prefer the composing states approach instead.

Is there a clean way to represent this via graph theory? Is there a general case algorithm that can deal with this preliminary requirement for being able to traverse a path? This problem is basically a 2-stage Dijkstra's search where you must visit a certain node first, but that node doesn't need to be visited if you already satisfy the "has passport" condition.

Guy Coder
  • 24,501
  • 8
  • 71
  • 136
Alexander Tsepkov
  • 3,946
  • 3
  • 35
  • 59
  • Thanks, I'm not familiar with Prolog either, but it does help that I can investigate how they handled it and if their use case is similar enough to mine. – Alexander Tsepkov Feb 04 '20 at 19:49
  • Since in Philly you get a pass, can't you make its outgoing edges go to NYCPass, ParisPass... Every xPass (any city ```x``` suffixed by ```Pass``` meaning you have the pass) can link to other yPass. An xPass can have for input only an yPass or Philly. So basically you have "dupplicated" node NYC to NYCPass (the second one meaning you belong to some bicomponent, where you have at least Philly as an articulation point). In which case inside US speaking, you may get "dupplicated" path but internationally you would have satisfied the constraint. It doubles the number of nodes of the whole graph – grodzi Feb 04 '20 at 21:57
  • @GuyCoder nope, not a homework problem. It's actually an enhancement I'm working on for my web-testing framework: https://github.com/atsepkov/drone. If you look at declarative section, you will see what this is for. The states/nodes are a set of user-defined tests algorithm performs to identify if they're in correct state (url/page/modal/etc). The fact that user is "logged in" can easily be tested as well, but creating a separate version of each page every time such branching occurs wouldn't scale. – Alexander Tsepkov Feb 04 '20 at 22:47
  • 1
    @grodzi the problem is that this approach not only doubles the number of nodes in this case, it doubles the number of nodes each time you encounter such branching condition. So if in addition to passport you need another item you decided to track, now you've quadrupled the number of states, and with 3 items you would have 8 times the number of states. – Alexander Tsepkov Feb 04 '20 at 22:58
  • I may be a bit thickheaded, but first pose the constraints, in particular maybe specify your problem without the generalization because there may be -typically- specific properties. 'My' underlying idea comes from the fact that in graph, you may handle states (typically A*). While pushing nodes, you push the successors only if your current one meet their condition. Nodes are the same, states are different, just the view of the mind changes... I wont insist more. I am still curious about how the prolog version will differ – grodzi Feb 05 '20 at 00:16
  • Thx for link, 46p is quite some read but i'll try. Regarding op's. Can a state of your fsm depend on some other states in an OR fashion: one of those must be visited? Or is this mandatorily an AND: all of the states must be visited? Or a state has at most one dependancy? – grodzi Feb 05 '20 at 09:16
  • 1
    @grodzi and GuyCoder thank you guys, still reading through the answers. Regarding ambiguities you mention, did you check out the link I posted earlier in the comment? That's the specific example, it's internal state machine of my web testing tool. An OR dependency would in fact be possible if an action could be performed in multiple states (i.e. logout can be performed from multiple pages). I think grodzi may be right that in many cases 2^n growth might not be a showstopper. I'll look more into this when I get home. – Alexander Tsepkov Feb 05 '20 at 22:31
  • Are we sure about FSM? Say you ```ensureState('#banner and #footer')``` both ```#banner``` and ```#footer``` can be present or not but are accessible once 'DOMLoaded'. Would you define 4 transitions from ```DOMLoaded``` ? Or would you define two from ```DOMLoaded``` (```#footer```, ```#banner```), then creating artificially a transition from ```#footer``` to ```#footerWith#banner``` and a transition from ```#banner``` to ```#bannerWith#footer```, Then again two to symetrically harmonize: to ```#bannerAnd#Footer```. FSM looks cumbersome, right? – grodzi Feb 06 '20 at 09:03
  • I downloaded the pdf anyway @Guy Coder ;) – grodzi Feb 06 '20 at 11:02

3 Answers3

2

Given these facts

connection(philly,nyc,no).
connection(nyc,philly,no).
connection(philly,harrisburg,no).
connection(harrisburg,philly,no).
connection(paris,nyc,yes).
connection(nyc,paris,yes).
passport(harrisburg).

where a connection has arguments from, to, passport needed

and these test cases

:- begin_tests(travel).

travel_test_case_generator( harrisburg ,harrisburg ,no  ,[harrisburg]                                                        ).
travel_test_case_generator( harrisburg ,harrisburg ,yes ,[harrisburg]                                                        ).
travel_test_case_generator( harrisburg ,philly     ,no  ,[harrisburg,philly]                                                 ).
travel_test_case_generator( harrisburg ,philly     ,yes ,[harrisburg,philly]                                                 ).
travel_test_case_generator( harrisburg ,nyc        ,no  ,[harrisburg,philly,nyc]                                             ).
travel_test_case_generator( harrisburg ,nyc        ,yes ,[harrisburg,philly,nyc]                                             ).
travel_test_case_generator( harrisburg ,paris      ,yes ,[harrisburg,philly,nyc,paris]                                       ).
travel_test_case_generator( harrisburg ,paris      ,no  ,[harrisburg,philly,nyc,philly,harrisburg,passport,philly,nyc,paris] ).
travel_test_case_generator( philly     ,philly     ,no  ,[philly]                                                            ).
travel_test_case_generator( philly     ,philly     ,yes ,[philly]                                                            ).
travel_test_case_generator( philly     ,nyc        ,no  ,[philly,nyc]                                                        ).
travel_test_case_generator( philly     ,nyc        ,yes ,[philly,nyc]                                                        ).
travel_test_case_generator( philly     ,paris      ,yes ,[philly,nyc,paris]                                                  ).
travel_test_case_generator( philly     ,paris      ,no  ,[philly,nyc,philly,harrisburg,passport,philly,nyc,paris]            ).
travel_test_case_generator( nyc        ,paris      ,yes ,[nyc,paris]                                                         ).
travel_test_case_generator( nyc        ,paris      ,no  ,[nyc,philly,harrisburg,passport,philly,nyc,paris]                   ).

test(001,[forall(travel_test_case_generator(Start,End,Passport,Expected_route))]) :-
    route(Start,End,Passport,Route),

    assertion( Route == Expected_route ).

:- end_tests(travel).

Here is the solution using . This code was written as a proof of concept to see how to answer the question. It was not written to the specs of the question so if you know Prolog you will find the obvious places where it can be improved or doesn't implement an algorithm as expected.

route(Start,End,Passport,Route) :-
    route(Start,End,Passport,[],Route_reversed),
    reverse(Route_reversed,Route), !.

route(City,City,_,Route0,Route) :-
    visit(City,Route0,Route).

route(A,C,yes,Route0,Route) :-
    connection(A,B,_),
    \+ member(B,Route0),
    visit(A,Route0,Route1),
    route(B,C,yes,Route1,Route).

route(A,C,no,Route0,Route) :-
    connection(A,B,Need_passport),
    \+ member(B,Route0),
    (
        (
            Need_passport == yes,
            \+ member(passport,Route0)
        )
    ->
        (
            get_passport_shortest(A,Route1),
            route(B,C,yes,[],Route2),
            reverse(Route0,Route0_reversed),
            append([Route0_reversed,[A],Route1,Route2],Route_reversed),
            reverse(Route_reversed,Route)
        )
    ;
        (
            visit(A,Route0,Route1),
            route(B,C,no,Route1,Route)
        )
    ).

route_no(A,A,no,Route,Route).
route_no(A,C,no,Route0,Route) :-
    connection(A,B,no),
    \+ member(B,Route0),
    visit(B,Route0,Route1),
    route_no(B,C,no,Route1,Route).

get_passport(A,Route) :-
    passport(B),
    route_no(A,B,no,[],Route1),
    route_no(B,A,no,[],Route2),
    reverse(Route1,Route1_reversed),
    reverse(Route2,Route2_reversed),
    append([Route1_reversed,[passport],Route2_reversed],Route).

visit(City,Route0,Route) :-
    (
        Route0 = [City|_]
    ->
        Route = Route0
    ;
        Route = [City|Route0]
    ).

get_passport_shortest(A,Shortest_route) :-
    findall(Route,get_passport(A,Route),Routes),
    select_shortest(Routes,Shortest_route).

select_shortest([H|T],Result) :-
    length(H,Length),
    select_shortest(T,Length,H,Result).

select_shortest([],_Current_length,Result,Result).
select_shortest([Item|T],Current_length0,Current_shortest0,Result) :-
    length(Item,Item_length),
    (
        Item_length < Current_length0
    ->
        (
            Current_length = Item_length,
            Current_shortest = Item
        )
    ;
        (
            Current_length = Current_length0,
            Current_shortest = Current_shortest0
        )
    ),
    select_shortest(T,Current_length,Current_shortest,Result).

When the test case are run

?- make.
% c:/so_question_159 (posted) compiled 0.00 sec, 0 clauses
% PL-Unit: travel ................ done
% All 16 tests passed
true.

All the test pass.


The reason the passport is in Harrisburg instead of Philly is that in testing the code, the code worked when the passport was in Philly. Then by adding Harrisburg and testing again a problem was uncovered in the code and fixed. If one changes passport(harrisburg). to passport(philly). the code will work but requires additional test cases.


Further questions posted in comments and moved here.


From grodzi

In your tests, the line (third from the end) philly, paris, no, why do philly,nyc,philly, harrisbug... when you can just do philly,harrisburg,philly... to get the passport? Is it intended or some minor bug?

Nice to see someone is paying attention. That is no bug and that was one of the test that exposed the bug when the passport was in Harrisburg. The way I interpret the problem as stated by the OP, the travel case is just an easier to understand version of his real problem related to an dynamic FSA with login and logout. So knowing that you need the passport is not known until you try to do the travel from nyc to paris. At this point you need the passport if it is not in hand and so need travel back to harrisbug to get it.

So yes that does look odd from a normal trip solver problem and we as humans can easily see the optimization, either because of experience, or superior reason ability, or peeking ahead and knowing that we need a passport to get to paris, but the system does not know it needs a passport until it is needed. I can add more rules and more conditions to this, but at present there is only the passport. If however the OP adds more conditions then I will ask for a new question because this question should have been more specific.


From OP

Regarding conditions being several layers deep, how does your example show that?

It doesn't at the moment because there were no rules needing to do that. It was posed as a question for others who have or plan to answer this as it will be a choice they would have to make when writing the code.


From OP

Does your example with the password window attempt to see how FSM handles user error?

No, I only looked at your basic ideas in the question posted.

This question is referring to the OPs code posted at GitHub


References of value

Attribute Grammars (Wikipedia)
Automated planning and scheduling (Wikipedia) (Prolog example)
RosettaCode Dijkstra's algorithm
SLD Resolution
Tabled execution (SLG resolution)
Declarative Programming - 3: logic programming and Prolog

Guy Coder
  • 24,501
  • 8
  • 71
  • 136
  • In your tests, the line (third from the end) ```philly, paris, no```, why do ```philly,nyc,philly, harrisbug...``` when you can just do ```philly,harrisburg,philly...``` to get the passport? Is it intended or some minor bug? – grodzi Feb 05 '20 at 19:10
  • I don't think this is (from OP's point of view) intended (he will clarify somehow). To me, in the drone.test, you specify the expected states (of the FSM), so there is "no" surprises. But it is just interpretation and I agree with you there are some blurry things from original question – grodzi Feb 05 '20 at 19:34
  • To me the required passport is known at the start. I imagine the drone.test as a tree of nodes, where every children of a node of that tree is some (FSM) state which must be reached before that very node. So basically(to me again), you know all the dependencies before starting. In our case you know which city requires which password (and if that city gives some visa for an other city, you know from that other city that you will need the password and the visa, the former one only as intermediary) – grodzi Feb 05 '20 at 20:20
  • @grodzi your interpretation is correct, since states are defined ahead of time one wouldn't need to hit the roadblock before addressing it. The program calculates the path in the beginning of `ensureState` call, prior to traversal, and chooses a route prior to traversal, therefore it would test the dependency during planning phase (as you suggested), not during execution (as GuyCoder suggested). – Alexander Tsepkov Feb 05 '20 at 22:57
  • @GuyCoder the FSM would analyze what's needed at the start of `ensureState` call, ideally it would proactively log in if it knows the final node needs a logged in state, as grodzi points out. However, where it starts out depends on previous operations performed, hence the "starting city" you can't control, you only use it to plan the path. Regarding conditions being several layers deep, how does your example show that? If user puts in the wrong password, they're still at the login window in logged out state. The state has not changed. – Alexander Tsepkov Feb 05 '20 at 22:59
  • @GuyCoder does your example with the password window attempt to see how FSM handles user error? I handle it in a same way a GPS system does, after each transition I test if we ended up in correct state the transition predicted. If we're still in starting state, I attempt the transition 3 (configurable) more times and give up. If we ended up in wrong state, I discard old plan and recalculate. Every time this occurs, I log the error and keep going. – Alexander Tsepkov Feb 05 '20 at 23:06
2

One can solve it with Astar by indeed "dupplicating" cities in a seemingly 2^n fashion (in practice this is less since not all the states will be explored).

A node is now a tuple <city, ...flags> where in this case, flags is the simple boolean to represent whether we are in possession of the passport or not.

Instead of basically considering the neighbours of some city C, we now consider the neighbours of the tuple T, which are the neighbours of T.city restricted to some rule:

If the neighbouring city requires a pass, T must have the pass in its flags

Below Astar, copy pasted from wiki. The only adaptation, is:

while generating the neighbours from some node, if node has pass, so have the neighbours.

Notice in tests (copied more or less from Guy Coder), two tests commented (which fail).

  • The first one, because harrisburg having the passport overrides in my case the absence of password specified as argument
  • The second one, because as commented, I am not expecting to come "back & forth" if not needed.

Note that the edges are not weighted d(a,b) = 1 forall existing (a,b) but they could/should be.

function h (node) { return 0 }
function d (a, b) { return 1 } // no weight but could be
const M = {
    harrisburg: [
      { c: 'philly', passRequired: false }
    ],
    nyc: [
      { c: 'philly', passRequired: false },
      { c: 'paris', passRequired: true }
    ],
    paris: [
      { c: 'nyc', passRequired: true }
    ],
    philly: [
      { c: 'harrisburg', passRequired: false },
      { c: 'nyc', passRequired: false }
    ]
}

const neighbours = node => {
    if (node.c === 'harrisburg') {
        return M[node.c].map(x => {
            return { c: x.c, hasPass: true }
        })
    }
    if (node.hasPass) {
        return M[node.c].map(x => Object.assign({ hasPass: true }, x))
    }
    return M[node.c].filter(x => !x.passRequired)
}
function id (node) { return node.c + !!node.hasPass }

//https://en.wikipedia.org/wiki/A*_search_algorithm
function reconstruct_path (cameFrom, current) {
  const total_path = [current]
  while(cameFrom.has(id(current))) {
    current = cameFrom.get(id(current))
    total_path.unshift(current)
  }
  return total_path
}


// A* finds a path from start to goal.
// h is the heuristic function. h(n) estimates the cost to reach goal from node n.
function A_Star(start, goal, h) {
  // The set of discovered nodes that may need to be (re-)expanded.
  // Initially, only the start node is known.
  const openSet = new Map([[id(start), start]])

  // For node n, cameFrom[n] is the node immediately preceding it on the cheapest path from start to n currently known.
  const cameFrom = new Map()

  // For node n, gScore[n] is the cost of the cheapest path from start to n currently known.
  const gScore = new Map()
  gScore.set(id(start), 0)

  // For node n, fScore[n] := gScore[n] + h(n).
  const fScore = new Map()
  fScore.set(id(start), h(start))

  while (openSet.size) {
    //current := the node in openSet having the lowest fScore[] value
    let current
    let bestScore = Number.MAX_SAFE_INTEGER
    for (let [nodeId, node] of openSet) {
      const score = fScore.get(nodeId)
      if (score < bestScore) {
        bestScore = score
        current = node
      }
    }
    
    if (current.c == goal.c) {
      return reconstruct_path(cameFrom, current)
    }
    openSet.delete(id(current))
    neighbours(current).forEach(neighbor => {
      const neighborId = id(neighbor)
      // d(current,neighbor) is the weight of the edge from current to neighbor
      // tentative_gScore is the distance from start to the neighbor through current
      const tentative_gScore = gScore.get(id(current)) + d(current, neighbor)
      if (!gScore.has(neighborId) || tentative_gScore < gScore.get(neighborId)) {
        // This path to neighbor is better than any previous one. Record it!
        cameFrom.set(neighborId, current)
        gScore.set(neighborId, tentative_gScore)
        fScore.set(neighborId, gScore.get(neighborId) + h(neighbor))
        if (!openSet.has(neighborId)){
          openSet.set(neighborId, neighbor)
        }
      }
    })
  }
  // Open set is empty but goal was never reached
  return false
}

function tests() {
  const assert = x => {
    if(!x){
      throw new Error(x)
    }
  }
  function travel_test_case_generator(from, to, initialPass, expect) {
    const res = A_Star({ c: from, hasPass: initialPass === 'yes'}, {c: to}, h).map(x => x.c)
    try {
    assert(res.length === expect.length)
    assert(res.every((x, i) => x === expect[i]))
    } catch (e) {
      console.log('failed', from, to, initialPass, res, expect)
      throw e
    }
    console.log('ok', `from ${from} to ${to} ${initialPass==='yes' ? 'with': 'without'} pass:`, res)
  }
  travel_test_case_generator( 'harrisburg' ,'harrisburg' ,'no'  ,['harrisburg'])
  travel_test_case_generator( 'harrisburg' ,'harrisburg' ,'yes' ,['harrisburg'])
  travel_test_case_generator( 'harrisburg' ,'philly'     ,'no'  ,['harrisburg', 'philly'])
  travel_test_case_generator( 'harrisburg' ,'philly'     ,'yes' ,['harrisburg', 'philly'])
  travel_test_case_generator( 'harrisburg' ,'nyc'        ,'no'  ,['harrisburg', 'philly', 'nyc'])
  travel_test_case_generator( 'harrisburg' ,'nyc'        ,'yes' ,['harrisburg', 'philly', 'nyc'])
  travel_test_case_generator( 'harrisburg' ,'paris'      ,'yes' ,['harrisburg', 'philly', 'nyc', 'paris'])
  // travel_test_case_generator( 'harrisburg' ,'paris'      ,'no'  ,['harrisburg', 'philly', 'nyc', 'philly', 'harrisburg', 'passport', 'philly', 'nyc', 'paris'])
  travel_test_case_generator( 'philly'     ,'philly'     ,'no'  ,['philly'])
  travel_test_case_generator( 'philly'     ,'philly'     ,'yes' ,['philly'])
  travel_test_case_generator( 'philly'     ,'nyc'        ,'no'  ,['philly', 'nyc'])
  travel_test_case_generator( 'philly'     ,'nyc'        ,'yes' ,['philly', 'nyc'])
  travel_test_case_generator( 'philly'     ,'paris'      ,'yes' ,['philly', 'nyc', 'paris'])
  // travel_test_case_generator( 'philly'     ,'paris'      ,'no'  ,['philly', 'nyc', 'philly', 'harrisburg', 'philly', 'nyc', 'paris'])
  travel_test_case_generator( 'nyc'        ,'paris'      ,'yes' ,['nyc', 'paris'])
  travel_test_case_generator( 'nyc'        ,'paris'      ,'no'  ,['nyc', 'philly', 'harrisburg', 'philly', 'nyc', 'paris'])
}
tests()
grodzi
  • 5,633
  • 1
  • 15
  • 15
  • mainly for consistency with your approach especially since I am reusing your test cases it feels awkward to give an other expected result. – grodzi Feb 05 '20 at 21:19
  • A story could be to add a few more passport involving ue and us (travelling from a us city to a ue city requires a ue pass, say delivered by harrisburg), then design some test cases for a path doing us->us and transiting by ue which requires the retrieval of both pass. This is not funny (to me) because I know it works. Somehow (if you want an estimate) this would require about 10-25 lines of js: modifying the 'rules' in function neighbours. What I am more interested in is how the algo behaves with a bigger graph (~200/1000nodes and some density, but I need to dedicate time for that) @Guy Coder – grodzi Feb 05 '20 at 22:04
1

What you call "composing states" is the usual way to do it. Sometimes it's called "graph layering". It's often used to solve "shortest path with constraint" sorts of problems.

The usual description would be like:

Make two copies of your state machine M1 and M2, M1 contains only transitions that you can take without your passport, M2 contains the transitions you can take with your passport. Then add a transition from M1 to M2 for every arc that aquires your passport. Now find the shortest path from the start state in M1 to the target state in either copy.

It is exactly, as you say, "adding a whole other dimension". If there are N vertices in your original graph and M supplementary states, the resulting graph has N*M vertices, so this is only practical if either N or M is smallish.

Here's a not bad lecture on the technique: https://www.youtube.com/watch?v=OQ5jsbhAv_M&feature=youtu.be&t=47m7s

And here are some other answers I've written using the same technique: https://stackoverflow.com/search?q=user%3A5483526+graph+layering

Note that in implementation, we don't usually make a real copy of the graph. We traverse the implicit graph using tuples to represent the composite states.

Matt Timmermans
  • 53,709
  • 3
  • 46
  • 87