Basic framework for search problems and solver.

The module provides a simple abstract class SearchProblem[S,A] to be specialized for a specific problem.

The concrete class SearchSolver is used to configure, query, and run a solver for a given problem.

For an example, see the puzzle.nit program in the examples subdirectory.

Introduced classes

class SearchNode[S: Object, A: nullable Object]

ai :: SearchNode

A node in the search-tree visited by a SearchSolver.
interface SearchProblem[S: Object, A: nullable Object]

ai :: SearchProblem

Abstract search problem over immutable states (S) and actions (A).
class SearchSolver[S: Object, A: nullable Object]

ai :: SearchSolver

A running solver for a given problem, to configure and control.

All class definitions

class SearchNode[S: Object, A: nullable Object]

ai $ SearchNode

A node in the search-tree visited by a SearchSolver.
interface SearchProblem[S: Object, A: nullable Object]

ai $ SearchProblem

Abstract search problem over immutable states (S) and actions (A).
class SearchSolver[S: Object, A: nullable Object]

ai $ SearchSolver

A running solver for a given problem, to configure and control.
package_diagram ai::search search realtime realtime ai::search->realtime trees trees ai::search->trees core core realtime->core trees->core ...core ... ...core->core ai::puzzle puzzle ai::puzzle->ai::search a_star-m a_star-m a_star-m->ai::puzzle a_star-m... ... a_star-m...->a_star-m


module abstract_collection

core :: abstract_collection

Abstract collection classes and services.
module abstract_text

core :: abstract_text

Abstract class for manipulation of sequences of characters
module abstract_tree

trees :: abstract_tree

Introduce tree structures abstraction
module array

core :: array

This module introduces the standard array structure.
module bintree

trees :: bintree

Binary Tree data-structure
module bitset

core :: bitset

Services to handle BitSet
module bytes

core :: bytes

Services for byte streams and arrays
module circular_array

core :: circular_array

Efficient data structure to access both end of the sequence.
module codec_base

core :: codec_base

Base for codecs to use with streams
module codecs

core :: codecs

Group module for all codec-related manipulations
module collection

core :: collection

This module define several collection classes.
module core

core :: core

Standard classes and methods used by default by Nit programs and libraries.
module environ

core :: environ

Access to the environment variables of the process
module error

core :: error

Standard error-management infrastructure.
module exec

core :: exec

Invocation and management of operating system sub-processes.
module file

core :: file

File manipulations (create, read, write, etc.)
module fixed_ints

core :: fixed_ints

Basic integers of fixed-precision
module fixed_ints_text

core :: fixed_ints_text

Text services to complement fixed_ints
module flat

core :: flat

All the array-based text representations
module gc

core :: gc

Access to the Nit internal garbage collection mechanism
module hash_collection

core :: hash_collection

Introduce HashMap and HashSet.
module iso8859_1

core :: iso8859_1

Codec for ISO8859-1 I/O
module kernel

core :: kernel

Most basic classes and methods.
module list

core :: list

This module handle double linked lists
module math

core :: math

Mathematical operations
module native

core :: native

Native structures for text and bytes
module numeric

core :: numeric

Advanced services for Numeric types
module protocol

core :: protocol

module queue

core :: queue

Queuing data structures and wrappers
module range

core :: range

Module for range of discrete objects.
module rbtree

trees :: rbtree

A red–black tree is a data structure which is a type of self-balancing binary search tree.
module re

core :: re

Regular expression support for all services based on Pattern
module ropes

core :: ropes

Tree-based representation of a String.
module sorter

core :: sorter

This module contains classes used to compare things and sorts arrays.
module stream

core :: stream

Input and output streams of characters
module text

core :: text

All the classes and methods related to the manipulation of text entities
module time

core :: time

Management of time and dates
module union_find

core :: union_find

union–find algorithm using an efficient disjoint-set data structure
module utf8

core :: utf8

Codec for UTF-8 I/O


module realtime

realtime :: realtime

Services to keep time of the wall clock time
module trees

trees :: trees

General module for tree data structures


module puzzle

ai :: puzzle

The N-puzzle problem, modeled naively as a SearchProblem.


module a_star-m


# Basic framework for search problems and solver.
# The module provides a simple abstract class `SearchProblem[S,A]` to be specialized for a specific problem.
# The concrete class `SearchSolver` is used to configure, query, and run a solver for a given problem.
# For an example, see the `puzzle.nit` program in the `examples` subdirectory.
module search

import realtime
import trees

# Abstract search problem over immutable states (`S`) and actions (`A`).
# This class serves to model problems of planing and path-finding.
# A state, `S`, is a point in the search problem and fully models a given state of the world.
# An action, `A`, is an available mean of transition between two states.
# This abstract class is generic made to work with any kind of states and actions.
# Therefore, specific subclasses must be developed to implement the required services:
# * `initial_state`
# * `actions`
# * `apply_action`
# * `is_goal`
# Note that the implemented methods should not temper with the parameters since it is expected
# that they remain unmodified.
# # Basic search
# These tree method are enough to trigger a basic search.
# The methods `breadth_first` and `depth_first` return pre-configured solvers for, respectively,
# a breadth-first search, a depth-first search.
# # Advanced search
# The `cost` method can be implemented to represent the cost of a single transition.
# By default, the cost is 1.
# The `heuristic` method can be implemented to represent a lower-bound estimation of the remaining
# cost to reach a goal state.
# By default, the heuristic is 0.
# If one of these (or both) methods are implemented, the `astar` method will return a pre-configured
# solver for a A* search.
# More configuration and optimization on the search can to be done in the `SearchSolver` class.
interface SearchProblem[S: Object, A]
	# The starting state for the problem
	fun initial_state: S is abstract

	# The available applicable actions for a given state.
	# While there is a potential large number of distinct states and actions, there should be only
	# a small number of possible action from a specific state (a small, or at least finite, branching factor).
	fun actions(state: S): nullable SequenceRead[A] is abstract

	# The new state when applying a given action
	# The returned state can be:
	# * a completely new state,
	# * an existing state,
	# * a new state but equals to an existing state
	#   in this case, ensure that the `==` and `hash` method
	#   are correctly implemented.
	# Remember, this method should not modify its parameters.
	# REQUIRE: `actions(state).has(action)`
	fun apply_action(state:S, action:A): S is abstract

	# Is the state a goal state?
	# Once a goal state is found, the solver is automatically stopped.
	# A problem can have 0, one or more goals if it makes sense
	# but solver must be used accordingly.
	# Default: no goals
	fun is_goal(state:S): Bool do return false

	# The cost for `action` from `old_state` to `new_state`
	# REQUIRE: `apply_action(old_state, action) == new_state`
	# Default: `1`.
	# Note that having a 0 or negative value can make some search
	# algorithms fail, or not terminate.
	fun cost(state:S, action:A, state2: S): Float do return 1.0

	# An heuristic of the estimated `cost` going from `state` to a goal state.
	# Is is expected that the heuristic is *admissible*, it means its is an
	# optimistic estimation and never an over-estimate, thus is cannot be
	# higher than the lowest possible remaining cost.
	# See `SearchSolver::do_revisit` for details.
	# Default: `0`.
	fun heuristic(state: S): Float do return 0.0

	# return a new breadth-first solver
	fun breadth_first: SearchSolver[S, A]
		var todo = (new Array[SearchNode[S, A]]).as_fifo
		var sol = new SearchSolver[S, A](self, todo)
		return sol

	# return a new depth-first solver
	fun depth_first: SearchSolver[S, A]
		var todo = (new List[SearchNode[S, A]]).as_lifo
		var sol = new SearchSolver[S, A](self, todo)
		return sol

	# return a new best-first solver
	# notes:
	# * if `heuristic` is not defined, this is basically a Dijkstra search.
	# * if `cost` in not defined either, this is basically a breadth-first search.
	fun astar: SearchSolver[S, A]
		var cpt = new NodeComparator[S, A]
		var todo = new MinHeap[SearchNode[S, A]](cpt)
		var sol = new SearchSolver[S, A](self, todo)
		return sol

	# Create the initial node in the search-tree.
	# Used internally by the solvers but made public for those who want to replay a plan.
	fun initial_node: SearchNode[S, A]
		var res = new SearchNode[S,A](self, initial_state, null, null, 0.0, 0)
		return res

	# Negligible quantity for float comparisons
	# Because of float imprecision, two really near float values should be considered equals.
	# However, the specific epsilon value could be specific to the problem.
	# The epsilon value is used for cost comparisons.
	# Default: 1E-9
	fun epsilon: Float do return 0.000000001

	# Run and evaluate solvers with multiple configuration.
	# This method can be used to evaluate which configuration to choose by default for a given problem.
	# `steps` is the maximum number of steps a giver configuration can run.
	fun run_configs(steps: Int)
		var c = 0
			if astar.run_config(steps, c, "A*") then break
			c += 1

	# Various Map implementations of memory.
	# In order to try more configurations with `run_config`, this method
	# is called to provide alternative implementation.
	# For instance, a subclass can redefine this method and extends the result with an additional `RBTreeMap`.
	# Note: because the true nature of the sates `S` is left to the user, some
	# specific Map implementation could be more efficient than a HashMop.
	# Default: A `HashMap`
	fun make_memory: Array[Map[S, SearchNode[S, A]]]
		var res = new Array[Map[S, SearchNode[S, A]]]
		res.add new HashMap[S, SearchNode[S, A]]
		return res

# A running solver for a given problem, to configure and control.
# For a given problem, a lot of variation of search algorithms can be made.
# Thus this class permit the user to control the parameters of the search algorithm.
# Note that this class is not meant to be specialized, and usually not instantiated directly.
# # Basic run and result.
# 1. Instantiate it with the method `breadth_first`, `depth_first`, or `astar` from `SearchProblem`.
# 2. Apply the method `run`, that will search and return a solution.
# 3. Retrieve information from the solution.
# ~~~~nitish
# var p: SearchProblem = new MyProblem
# var res =
# if res != null then print "Found plan with {res.depth} actions, that cost {res.cost}: {res.plan.join(", ")}"
# ~~~~
# # Step-by-step runs and multiple runs
# The `run_steps` method (see also `steps`, and `steps_limit`) can be used to run only a maximum number of steps.
# This method can be used as a *co-routine* and run them periodically for a small amount of time.
# `run` and `run_steps` return the next solution.
# A subsequent call to `run` returns the following solution and so on.
# When there is no more solutions available, `is_running` become false.
# # Search-trees
# Internally, solvers use a search-tree where nodes are labeled with states, and edges are labeled with actions.
# See `SearchNode` for details.
# The `run` method return a `SearchNode` that can be used to retrieve a lot of useful information,
# like the full `path` or the `plan`.
# # Configuration
# The solver provide some *knobs* to control how the search-tree is visited.
# * `memorize` (see also `memorize_late`)
# * `do_revisit` (see also `revisits`)
# * `depth_limit` (see also `iterative_deepening` and `depth_limit_reached`)
class SearchSolver[S: Object, A]
	# The problem currently solved
	var problem: SearchProblem[S, A]

	# The currently open nodes to process.
	# They are the open nodes.
	# It is the nature of the queue that control how the solver works.
	# However, the methods `SearchProblem::breadth_first`, `SearchProblem::depth_first`,
	# and `SearchProblem::astar` takes care of its correct initialization.
	private var todo: Queue[SearchNode[S, A]]

	# Is the solver still running?
	# A running solver has not yet exhausted all the possible solutions.
	var is_running: Bool = true

	# Does the solver need to memorize visited states?
	# When activated, there is an increased memory consumption since
	# all visited states must be kept in memory,
	# However, there is real a gain, since visited nodes are not
	# revisited (unless needed by `do_revisit`)
	# Default: `true`
	# Note: If the graph of states has circuits, then a memory-less search may not terminate.
	var memorize: Bool = true is writable

	# Use memory only on visited (closed) state.
	# Less memory operations, but two big drawbacks:
	# * duplicated nodes can fill the `todo` queue (and the memory)
	# * duplicated nodes require more invocation of `SearchProblem::heuristic`
	# Note: if `memorize` is false, then this has no effect.
	# Default: `false`
	var memorize_late: Bool = false is writable

	# Storage of nodes when `memorize` is activated
	# Each state is associated with a node.
	# This permit:
	#   * to avoid to revisit visited nodes (see `do_revisit`)
	#   * to avoid to reinvoke `heuristic` on known states (unless `memorize_late` is set)
	private var memory: Map[S, SearchNode[S, A]] = new HashMap[S, SearchNode[S, A]]

	# Total number of time an already memorized node is seen again.
	# If `memorize_late` is set, then only visited nodes are counted.
	# Otherwise, nodes in the todo-list are also considered.
	var memorized = 0

	# Revisit states when a better path to them is found.
	# Such revisits generally triggers more revisits because they yield
	# better path to their neighbors.
	# If `false`, visited states are never revisited.
	# With astar and an admissible heuristic, no visited node should be revisited.
	# If the heuristic is not admissible, one may consider set this to `true`.
	# Obviously, if `memorize` is false, then the value has no specific effect
	# since all states are considered unvisited.
	# Default: `false`.
	# See also `revisits` and `SearchNode::revisits`.
	var do_revisit: Bool = false is writable

	# Total number of states (potentially) revisited.
	# It is the number of time that a better path to a visited state is found.
	# With astar and a really admissible heuristic, this number should stay 0.
	# So check this value if you are not sure of the heuristic.
	# Note that states are effectively revisited if `do_revisit` is activated.
	var revisits = 0

	# The solution found by the last `run`.
	# ensure `solution != null implies problem.is_goal(solution.state)`
	var solution: nullable SearchNode[S,A] = null

	# Nearest solution found (up to date).
	# The nearest solution is the one with the smallest heuristic value.
	# The cost is not considered.
	var nearest_solution: nullable SearchNode[S,A] = null

	# Limit in the depth search.
	# States found above this limit are not considered.
	# Use 0 for no limit.
	# Default: 0
	# See also: `iterative_deepening`
	var depth_limit: Int = 0 is writable

	# How much time a `depth_limit` was reached?
	# This can be used to query if some solutions may have been
	# ignored because of a `depth_limit`.
	# This is also used automatically if `iterative_deepening` is activated.
	var depth_limit_reached: Int = 0

	# Increase amount for an iterative deepening search.
	# It =0, then the iterative deepening search is disabled.
	# If >0, then `depth_limit` is automatically increased when the todo
	# queue is empty but the `depth_limit` was reached in the previous iteration.
	# Default: 0
	var iterative_deepening: Int = 0

	# The total steps executed since the beginning
	# A step is the visit of a node in the `todo`-list
	var steps: Int = 0

	# The total number of nodes created
	var nodes = 0

	# Limit in the number of steps for a `run`.
	# One can modify this value then `run` or just call `run_steps`.
	# Use 0 for no limit.
	# Default: 0
	var steps_limit: Int = 0 is writable

	# Total number of neighbors considered.
	var neighbors = 0

	# The average number of neighbors by nodes.
	fun branching_factor: Float do return neighbors.to_f / steps.to_f

	# Update `steps_limit` then just run some additional steps
	# Return the best solution so far (if any)
	fun run_steps(steps: Int): nullable SearchNode[S,A]
		assert steps > 0
		self.steps_limit = self.steps + steps
		return run

	# Reset the search from the initial state.
	# Is used at the beginning and with `iterative_deepening`.
	private fun start
		assert todo.is_empty
		depth_limit_reached = 0
		var initial_node = problem.initial_node
		if memorize and not memorize_late then memory[initial_node.state] = initial_node = nodes
		nodes += 1
		todo.add initial_node

	# Run the solver and return the next solution (if any)
	# Return null is one of these is true:
	# * `steps_limit` is reached
	# * the `todo` queue is empty (eg. no reachable solution)
	fun run: nullable SearchNode[S,A]
		if steps == 0 then start

		var nearest = nearest_solution
			# Enough work
			if steps_limit > 0 and steps >= steps_limit then break

			#print "todo={todo.length}"
			#print "  {todo.join("\n  ")}"

			# Next node, please
			if todo.is_empty then
				# iterative depth search?
				if depth_limit <= 0 or iterative_deepening <= 0 or depth_limit_reached == 0 then
					is_running = false

				depth_limit += iterative_deepening
			var node = todo.take

			# Skip `old` stuff
			# Because `Queue` has no remove :(
			if node.drop then continue

			var state = node.state

			if memorize and memorize_late then
				# Is the state already visited?
				var old = memory.get_or_null(state)
				if old != null then
					memorized += 1
					if old.cost - node.cost < problem.epsilon then continue
					revisits += 1
					if not do_revisit then
						old.revisits += 1
					node.revisits = old.revisits + 1
				memory[state] = node

			steps += 1
			assert node.steps == 0
			node.steps = steps
			self.node = node

			# Keep trace to the nearest
			if nearest == null or node.heuristic < nearest.heuristic then
				nearest = node
				nearest_solution = node

			#print "try {node}"
			#print "try {node}; todo={todo.length}"

			# Won?
			if problem.is_goal(state) then
				solution = node
				return node

			# Ignore successors states if the depth limit is reached
			if depth_limit > 0 and node.depth >= depth_limit then
				depth_limit_reached += 1

			# Now, expand!
			var actions = problem.actions(state)
			if actions == null then continue
			for action in actions do
				neighbors += 1

				# Fast track if no memory or late memory
				if not memorize or memorize_late then
					var new_node = node.apply_action(action) = nodes
					nodes += 1

				# Get the state and the cost. Do not create the node yet.
				var new_state = problem.apply_action(state, action)
				var new_cost = node.cost + problem.cost(state, action, new_state)

				# So check if the state was already seen
				var old = memory.get_or_null(new_state)
				if old != null then
					memorized += 1
					# If not better, then skip
					if old.cost - new_cost < problem.epsilon then continue
					# If visited and do not revisit, then skip
					if old.steps > 0 and not do_revisit then
						old.revisits += 1
						revisits += 1
					# Even if `==`, reuse the same state object so
					# * it may helps the GC
					# * user-cached things in the previous state can be reused
					new_state = old.state

				# Finally, create the node
				var new_node = new SearchNode[S, A](problem, new_state, node, action, new_cost, node.depth+1) = nodes
				nodes += 1

				if old == null then
					# Compute heuristic and cost
					# Reuse heuristic and update the cost
					var h = old.heuristic
					new_node.heuristic = h
					new_node.score = new_cost + h

					# Is `old` a visited node?
					if old.steps == 0 then
						# Old is still in the todo list, so drop it
						old.drop = true
						# Old was visited, so revisit it
						new_node.revisits = old.revisits + 1
						revisits += 1
						#print "found {old.cost}>{new_cost}:{old.cost>new_cost} d={old.cost-new_cost}\n\t{old}\nthat is worse than\n\t{new_node}"
				memory[new_state] = new_node

		return null

	# The last visited node.
	# Unless when debugging, the last visited node is not really meaningful.
	var node: nullable SearchNode[S, A] = null

	redef fun to_s
		var res ="steps={steps} nodes={nodes} todo={todo.length}"
		if neighbors > 0 then res += " n={neighbors} (bf={branching_factor})"
		if revisits > 0 then res += " re={revisits}"
		if memorized > 0 then res += " mem={memorized}"
		var n = solution
		if n != null then
			res += " last={n}"
			n = nearest_solution
			if n != null then res += " near={n}"
		return res

	# Run the configuration number `i`, for `steps` number of steps.
	# The message `msg` suffixed by the name of the configuration is printed followed by the result
	# This method is used by `SearchProblem::run_configs`
	fun run_config(steps: Int, i: Int, msg: String): Bool
			if i == 0 then
				msg += " -mem"
				memorize = false
			i -= 1

			var mems = problem.make_memory
			memory = mems[i % mems.length]
			msg += " {memory.class_name}"
			i = i / mems.length

			if i % 2 == 0 then
				msg += " +mem"
				memorize = true
				memorize_late = false
				msg += " +mem_late"
				memorize = true
				memorize_late = true
			i = i / 2

			if i % 2 == 0 then
				msg += " +revisit"
				do_revisit = true
				msg += " -revisit"
				do_revisit = false
			i = i / 2

			if i >= 1 then return true

		print msg

		var t = new Clock
		print "\t{self}"
		var l = t.lapse
		print "\ttime={l}"
		return false

# Used to compare nodes with their score.
# Smaller is score, smaller is the node.
private class NodeComparator[S: Object, A]
	super Comparator
	redef type COMPARED: SearchNode[S, A]
	redef fun compare(a,b) do return a.score <=> b.score

# A node in the search-tree visited by a `SearchSolver`.
# In search-trees, nodes are labeled with states (`S`), and edges by actions (`A`).
# The root node is labeled by the initial state of the problem.
# This class is exposed to allow queries on the solution provided by the solver.
class SearchNode[S: Object, A]
	# A flag that indicate that `self` is virtually removed from the todo-list.
	# `self` was added to the todo-list but that a better node for the
	# same state was found latter.
	private var drop = false

	# The associated problem
	var problem: SearchProblem[S, A]

	# The state associated to `self`.
	# The state labels the node `self`.
	var state: S

	# Is `self` a root node of the search-tree?
	# ensure: `result` == `parent == null` and `result`== `action == null`
	fun is_root: Bool do return parent == null

	# The previous node in the search-tree (if not root).
	var parent: nullable SearchNode[S, A]

	# The action used to go from `parent` to `self` (if not root)
	# The action labels the edge from `parent` to `self`.
	var action: nullable A

	# The past cost (g) from the root node to `self`.
	var cost: Float

	# The heuristic from self to the goal (according to `problem.heuristic(state)`
	# It is the future cost (h)
	var heuristic: Float is noinit

	# The sum of `cost` and `heuristic`
	# It is the f function.
	var score: Float is noinit

	# Update `heuristic` and `score` according to `problem`.
	private fun compute_heuristic
		var h = problem.heuristic(state)
		heuristic = h
		score = cost + h

	# The depth of `self` in the search tree
	# It is the number of parents to the root node.
	var depth: Int

	# The number of steps needed by the solver to process `self`
	# It is just a useless generation number, but could be used to evaluate
	# the behavior of search algorithms.
	var steps: Int = 0

	# The rank of creation of nodes by the solver.
	# It is just a useless generation number, but could be used to evaluate
	# the behavior of search algorithms.
	var id: Int = 0

	# The number of (potential) revisits of `node`.
	# This information can be used to debug search algorithms.
	# And to detect when heuristics are not admissible.
	# See `SearchSolver::revisits` and `SearchSolver::do_revisit`
	# for details.
	var revisits: Int = 0

	# Create a new child node for the next state, according to `problem`.
	# Used internally by the solvers but made public for those who want to replay a plan.
	# ensure `result.parent == self`
	# ensure `result.action == action`
	fun apply_action(action: A): SearchNode[S, A]
		var new_state = problem.apply_action(state, action)
		var new_cost = problem.cost(state, action, new_state)
		var new_node = new SearchNode[S, A](problem, new_state, self, action, cost + new_cost, depth+1)
		return new_node

	# Build the sequence of nodes from the initial node to `self`
	# ensure `result.first.is_root and result.last == self`
	fun path: Sequence[SearchNode[S, A]]
		var res = new List[SearchNode[S, A]]
		var node = parent
		while node != null do
			node = node.parent
		return res

	# Build a sequence of actions from the initial state to `self`
	# See `path` for a more detailed plan.
	fun plan: Sequence[A]
		var res = new List[A]
		var node: nullable SearchNode[S, A] = self
		while node != null do
			var a = node.action
			if a != null then res.unshift(a)
			node = node.parent
		return res

	# Just print a detailed path on the screen
	fun dump
		print "result:{state}"
		for n in path do
			var a = n.action
			if a != null then print "    + {a}"
			print "  {n.steps}: {n.state} ({n.cost}$)"

	redef fun to_s do return "#{steps}/{id} d={depth} f={cost+heuristic} g={cost} h={heuristic}: {state}"
	#redef fun to_s do return "#{steps} f={(cost+heuristic).to_i} g={cost.to_i} h={heuristic.to_i}"