Tutorials

Getting started with SDK and store

Learn the basics of modeling with the Nextmv SDK and the store of variables

A tour of data store modeling with the Nextmv Software Development Kit (SDK).

Christopher Robin goes
Hoppity, hoppity,
Hoppity, hoppity, hop.
Whenever I tell him
Politely to stop it, he
Says he can't possibly stop.

If he stopped hopping,
He couldn't go anywhere,
Poor little Christopher
Couldn't go anywhere...
That's why he always goes
Hoppity, hoppity,
Hoppity,
Hoppity,
Hop.

-- A. A. Milne

The Nextmv SDK lets you automate any operational decision in a way that looks and feels like writing other code. It provides the guardrails to turn your data into automated decisions, and test and deploy them into production environments.

Let's see how.

Hello Nextmv

This tutorial assumes you already completed the steps described in the 5-minute getting started experience and that you fulfill these prerequisites:

  • Nextmv CLI available and configured.

To test that the Nextmv CLI is correctly configured, run the following command on your terminal. It will get some files that are necessary to work with the Nextmv Platform.

$ nextmv install
checking go and sdk version support...
downloading sdk files...
Copy

Now that you are all set up, you can officially start the tour. Any decision model built on the Nextmv SDK requires a a Go module. A module manages your dependencies, including the Nextmv SDK. Create one called tour.

mkdir tour

cd tour

go mod init tour
Copy

Now add the Nextmv SDK to your dependencies.

go get github.com/nextmv-io/sdk
Copy

You should now have a go.mod file that looks like this:

tour$ cat go.mod
module tour

go 1.18

require github.com/nextmv-io/sdk vX.Y.Z // indirect

Create a new hello/ dir (known as a package, in Go).

mkdir hello
Copy

Create a main.go file that looks just like this and save it in the tour/hello/ dir.

package main

import (
	"fmt"

	"github.com/nextmv-io/sdk"
	"github.com/nextmv-io/sdk/store"
)

func main() {
	s := store.New()
	version := store.NewVar(s, sdk.VERSION)
	fmt.Println("Hello Nextmv", version.Get(s))
}
Copy

Run the program using the Nextmv CLI.

tour$ nextmv run local hello/main.go
--------------------------------------------------------------------------------
This software is provided by Nextmv.

ยฉ 2019-2022 nextmv.io inc. All rights reserved.

    (\/)     (\/)     (\/)     (\/)     (\/)     (\/)     (\/)     (\/)     (\/)
    (^^)     (^^)     (^^)     (^^)     (^^)     (^^)     (^^)     (^^)     (^^)
   o( O)    o( O)    o( O)    o( O)    o( O)    o( O)    o( O)    o( O)    o( O)
--------------------------------------------------------------------------------
Hello Nextmv vX.Y.Z
Copy

If you see output like the above, you're ready to get hopping! Each of the examples in this tour constitutes a complete main.go. Put them in unique directories inside your tour/ folder and run them using the same nextmv run command shown above. Note that the banner is printed to stderr and the actual output to stdout, meaning the banner will never be part of the output.

Creating stores

A Store is lexical scope, similar to the lexical scope in most programming languages. It can hold variable declarations, variable assignments, and logic.

s := store.New()
Copy

You can add any Var to a store. The store will manage the variables for you.

x := store.NewVar(s, 42)                    // x is a stored int
y := store.NewVar(s, []float64{3.14, 2.72}) // y is a stored []float64
Copy

You can retrieve typed variable values from the store with their Get methods. The store knows which type they are so you don't have to think about it.

fmt.Println(
	x.Get(s)*10,
	y.Get(s)[0],
)
Copy

You can put this together and try it. This is the complete main.go program:

package main

import (
	"fmt"

	"github.com/nextmv-io/sdk/store"
)

func main() {
	s := store.New()

	x := store.NewVar(s, 42)                    // x is a stored int
	y := store.NewVar(s, []float64{3.14, 2.72}) // y is a stored []float64

	fmt.Println(
		x.Get(s)*10,
		y.Get(s)[0],
	)
}
Copy

You can save it in its own directory under tour/. For example, tour/creating-stores/main.go works nicely. Run it using the Nextmv CLI. You should obtain an output such as this:

tour$ nextmv run local creating-stores/main.go
420 3.14
Copy

Note that the banner is not displayed for compactness. In future examples, we'll leave out those steps. You can save the files wherever you like under the tour/ folder, so long as there is only one func main() in any subfolder.

Exercises - creating stores

  • Add more variables of different types to the store. Print their values.
  • Create a second store and add variables to it.
  • What happens when you retrieve a value from the wrong store?

Updating data

You can think of a store as a lexical scope containing variable declarations, variable assignments, and logic. Thus a store has similar mechanics to a block in a lexically scoped programming language without destructive assignment. For example, say you have outer and inner blocks with the following assignments.

{
    x = 42
    y = "foo"

    {
        y = "bar"
        pi = 3.14
    }
}
Copy

The outer block contains two variables, x = 42 and y = "foo". The inner block inherits x = 42 from the outer block which contains it, overrides y = "bar", and adds a new variable pi = 3.14. Assignments in the inner block do not impact the outer block.

You can code this example up. First define a store s1 and add x and y to it.

s1 := store.New()
x := store.NewVar(s1, 42)
y := store.NewVar(s1, "foo")
Copy

Now apply a changeset to s1. This results in a new store, s2. s2 is functionally a copy of s1 with a new value associated with y and a new variable, pi.

s2 := s1.Apply(y.Set("bar"))
pi := store.NewVar(s2, 3.14)
Copy

If you query:

fmt.Println("s1:", x.Get(s1), y.Get(s1))
fmt.Println("s2:", x.Get(s2), y.Get(s2), pi.Get(s2))
Copy

And put everything together in this main.go source:

package main

import (
	"fmt"

	"github.com/nextmv-io/sdk/store"
)

func main() {
	s1 := store.New()
	x := store.NewVar(s1, 42)
	y := store.NewVar(s1, "foo")

	s2 := s1.Apply(y.Set("bar"))
	pi := store.NewVar(s2, 3.14)

	fmt.Println("s1:", x.Get(s1), y.Get(s1))
	fmt.Println("s2:", x.Get(s2), y.Get(s2), pi.Get(s2))
}
Copy

You can run the program above and see output similar to this:

tour$ nextmv run local updating-data/main.go
s1: 42 foo
s2: 42 bar 3.14

Note that calling y.Set("bar") creates a Change. The Apply method accepts any number of changes and applies them in order to create a new store. Thus the code below creates a single store with x = 10 and y = "abc".

s1.Apply(
    x.Set(-3),
    y.Set("bar"),
    y.Set("abc"),
    x.Set(100),
    x.Set(10),
)
Copy

Exercises - updating data

  • Apply multiple changes to s1 to create a new store s3. Does s3 impact s2 in any way?
  • Try to store a slice on s1 then append to it when applying changes to create both s2 and s3. What do you expect to happen? What happens?

Storing custom data

A store can manage any concrete type, including custom structs. You can define a bunny type with a few fields and a String method.

type bunny struct {
	name       string
	fluffiness float64
	activities []string
}

func (b bunny) String() string {
	fluffy := "fluffy"
	if b.fluffiness < 0.5 {
		fluffy = "not fluffy"
	}

	return fmt.Sprintf("%s is %s and likes %v", b.name, fluffy, b.activities)
}
Copy

Now create a bunny and add it to the store.

s := store.New()

peter := store.NewVar(s, bunny{
	name:       "Peter Rabbit",
	fluffiness: 0.52,
	activities: []string{
		"stealing vegetables",
		"losing shoes",
	},
})
Copy

If you retrieve the value of peter from the store and print it, you should get the results of the bunny.String method. Note that peter.Get returns a value of type bunny, so Go knows to call the appropriate String method when you pass it to fmt.Println.

fmt.Println(peter.Get(s))
Copy

Here is the complete main.go program.

package main

import (
	"fmt"

	"github.com/nextmv-io/sdk/store"
)

type bunny struct {
	name       string
	fluffiness float64
	activities []string
}

func (b bunny) String() string {
	fluffy := "fluffy"
	if b.fluffiness < 0.5 {
		fluffy = "not fluffy"
	}

	return fmt.Sprintf("%s is %s and likes %v", b.name, fluffy, b.activities)
}

func main() {
	s := store.New()

	peter := store.NewVar(s, bunny{
		name:       "Peter Rabbit",
		fluffiness: 0.52,
		activities: []string{
			"stealing vegetables",
			"losing shoes",
		},
	})

	fmt.Println(peter.Get(s))
}
Copy

Run this source and you should see an educational message about this Peter Rabbit fellow.

tour$ nextmv run local storing-custom-data/main.go
Peter Rabbit is fluffy and likes [stealing vegetables losing shoes]

Exercises - storing custom data

  • Add another bunny to the store and call its String method directly.
  • Add another method to the bunny type. Retrieve peter from the store and call this new method.

Formatting stores

The store was designed to work well with JSON data. This makes it easy to deploy models into microservices, run them as serverless functions, and many other things. A store can be directly encoded into JSON as a representation of its variable assignments.

You can create a new store and encode it into JSON.

enc := json.NewEncoder(os.Stdout)

s := store.New()
if err := enc.Encode(s); err != nil {
	panic(err)
}
Copy

This should write an empty list to stdout because the store is empty.

[]

You can add some variables to the store.

x := store.NewVar(s, 42)
y := store.NewVar(s, "foo")
pi := store.NewVar(s, 3.14)
if err := enc.Encode(s); err != nil {
	panic(err)
}
Copy

By default, the JSON representation of a store contains its variable assignments in order of declaration.

[42, "foo", 3.14]
Copy

This may be fine, or you may rather reshape the format into something more convenient. You can turn that JSON list into a map with the variable names. To do this, use the Format method. Format is similar to Apply, in that it doesn't change the existing store, but applies a change to create a new one. The difference is that now you are adding logic to the store instead of a change to a variable assignment. Stores give you very specific ways to introduce logic.

s = s.Format(func(s store.Store) any {
	return map[string]any{
		"x":  x.Get(s),
		"y":  y.Get(s),
		"pi": pi.Get(s),
	}
})
if err := enc.Encode(s); err != nil {
	panic(err)
}
Copy

Now you should see your store encoded as a map. This isn't that far from where you started, but is more useful! Since Format can reshape a store into anything that can be encoded in JSON, it is easy to make the output match anything you might expect in a production environment.

{
  "pi": 3.14, 
  "x": 42, 
  "y": "foo"
}
Copy

Finally, apply a change to a variable assignment and encode the resulting store.

if err := enc.Encode(s.Apply(y.Set("bar"))); err != nil {
	panic(err)
}
Copy

Note how the new store inherits the formatting logic.

{
  "pi": 3.14, 
  "x": 42, 
  "y": "bar"
}
Copy

Here is the complete main.go program you may run to obtain the results discussed in this section.

package main

import (
	"encoding/json"
	"os"

	"github.com/nextmv-io/sdk/store"
)

func main() {
	enc := json.NewEncoder(os.Stdout)

	s := store.New()
	if err := enc.Encode(s); err != nil {
		panic(err)
	}

	x := store.NewVar(s, 42)
	y := store.NewVar(s, "foo")
	pi := store.NewVar(s, 3.14)
	if err := enc.Encode(s); err != nil {
		panic(err)
	}

	s = s.Format(func(s store.Store) any {
		return map[string]any{
			"x":  x.Get(s),
			"y":  y.Get(s),
			"pi": pi.Get(s),
		}
	})
	if err := enc.Encode(s); err != nil {
		panic(err)
	}

	if err := enc.Encode(s.Apply(y.Set("bar"))); err != nil {
		panic(err)
	}
}
Copy

Exercises - formatting stores

  • Start with the source above and reshape the output into something more complex than a map. Can you format it as a map of maps or as a user-defined structure? How do the rules of the encoding/json library apply to the output?
  • What happens if you override the formatting logic of a child store? Does that impact the parent or any sibling stores?

Slices

Go passes arguments to functions and makes variable assignments by value. This applies to stores as well: a store will assign to a declared variable by value. If that value is of a type like an int or a struct, then it is copied before assigning it to a variable in a store. This is unsurprising.

It gets a little hairier when assigning a pointer type like a slice or a map to a variable. The pointer value is copied, but not the data it refers to. While the store doesn't keep you from storing pointers, it's usually not what you want. Instead, it provides immutable containers so you can update rich data structures in a safe and efficient manner in your model.

The simplest of these container types is an immutable Slice. You can store a slice using the special NewSlice function.

s1 := store.New()
x := store.NewSlice(s1, "c", "d", "e") // []string{"c", "d", "e"}
Copy

You can assign any type to the slice. In this case, the store infers that you are storing a slice of strings with initial value ["c", "d", "e"] from the parameters. If you want to create an empty slice, the store needs to know the type.

x := store.NewSlice[string](s1) // []string{}
Copy

The slice x has several methods that query properties of the slice for a given store, such as Get, which retrieves the value of an index, and Len, which returns the length of the slice. The Slice method returns the underlying slice data as a standard Go slice, like []string.

It also provides methods for creating changes. You may apply these changes to create new stores.

s2 := s1.Apply(
	x.Append("h", "i", "j"),
	x.Prepend("a", "y", "z"),
)

s3 := s2.Apply(
	x.Insert(6, "f", "g"),
	x.Remove(2, 2),
	x.Set(1, "b"),
)
Copy

Here is the complete main.go program.

package main

import (
	"fmt"

	"github.com/nextmv-io/sdk/store"
)

func main() {
	s1 := store.New()
	x := store.NewSlice(s1, "c", "d", "e") // []string{"c", "d", "e"}

	s2 := s1.Apply(
		x.Append("h", "i", "j"),
		x.Prepend("a", "y", "z"),
	)

	s3 := s2.Apply(
		x.Insert(6, "f", "g"),
		x.Remove(2, 2),
		x.Set(1, "b"),
	)

	fmt.Println(x.Slice(s1))
	fmt.Println(x.Slice(s2))
	fmt.Println(x.Slice(s3))
}
Copy

Run this source and you should see the slice as it is stored across different stores.

[c d e]
[a y z c d e h i j]
[a b c d e f g h i j]
Copy

Exercises - slices

  • Try to guess what s1, s2, and s3 contain. Run the source and see if you are right.
  • Create a slice of a user-defined type. Insert values into it and retrieve the underlying slice contents.
  • Use the Len and Get methods to iterate over a slice and print its values one at a time.

Maps

Just like stores provide an immutable slice type and various methods for creating new slices from existing ones, it also provides a Map collection. Like slices, maps can store any type of value. However, they only allow either int or string keys.

A map is initialized empty and therefore requires its key and value types.

s1 := store.New()
x := store.NewMap[string, float64](s1) // map[string]float64{}
Copy

You can assign values to keys in a map using its Set method. Like other types associated with a store, instead of mutating the underlying data in a maps, this returns a change to apply to a new store.

s2 := s1.Apply( // map[string]float64{"pi": 3.14, "e": 2.72}
	x.Set("pi", 3.14),
	x.Set("e", 2.72),
)
Copy

Maps can assign new values to existing keys through subsequent calls to Set. They can also remove keys entirely using their Delete method.

s3 := s2.Apply(x.Delete("e")) // map[string]float64{"pi": 3.14}
Copy

Among the other methods available on the map collection, the Map method returns its underlying representation.

fmt.Println(x.Map(s3))
Copy

Here is the complete main.go program.

package main

import (
	"fmt"

	"github.com/nextmv-io/sdk/store"
)

func main() {
	s1 := store.New()
	x := store.NewMap[string, float64](s1) // map[string]float64{}

	s2 := s1.Apply( // map[string]float64{"pi": 3.14, "e": 2.72}
		x.Set("pi", 3.14),
		x.Set("e", 2.72),
	)

	s3 := s2.Apply(x.Delete("e")) // map[string]float64{"pi": 3.14}

	fmt.Println(x.Map(s1))
	fmt.Println(x.Map(s2))
	fmt.Println(x.Map(s3))
}
Copy

Run this source and you should see the map as it is stored across different stores.

map[]
map[e:2.72 pi:3.14]
map[pi:3.14]
Copy

Exercises - maps

  • Try to guess what s1, s2, and s3 contain. Run the source and see if you are right.
  • Create a map with int keys and values of a custom type. Set values on the map and retrieve its underlying representation.

Domains

Domains are a special type. A domain stores integers which typically represent potential choices. For example, a domain may represent the hours a shift might start or the destinations a traveler could arrive at.

Structurally, a domain is an ordered, compact, set of integers. Domains maintain a minimal representation of ranges as we apply operators to them to create new ones. They are also, conveniently, immutable.

The Nextmv SDK provides two domain types: model.Domain and store.Domain. model.Domain is just an integer domain, unattached to a store. store.Domain has many of the same methods but with similar mechanics to store.Slice and store.Map. Thus, model.Domain is the underlying type for store.Domain, which must be associated with a store.

You can create a few domains to see how they work.

s1 := store.New()
d1 := store.NewDomain(s1)
d2 := store.NewDomain(s1, model.NewRange(1, 10))
d3 := store.NewDomain(s1, model.NewRange(-5, 5), model.NewRange(15, 25))
Copy

You can pass as many integer ranges as you want to store.NewDomain. If you pass none, the domain is empty, like d1. d2 contains the integers 1 through 10, while d3 contains two disjoint ranges of integers, -5 though 5 and 15 through 25.

You can apply a changeset to these domains and encode the store into JSON.

s2 := s1.Apply(
	d1.Add(42, 43, 44),
	d2.Remove([]int{2, 4, 6, 8, 10}),
	d3.AtLeast(10),
)

enc := json.NewEncoder(os.Stdout)
if err := enc.Encode(s2); err != nil {
	panic(err)
}
Copy

In s2, d1 contains 42 through 44, d2 contains only odd numbers, and d3 contains the range 15 through 25. Note that each of these domains is encoded in a compact JSON representation. This is similar to its internal data.

[
  [[42,44]],
  [1,3,5,7,9],
  [[15,25]]
]
Copy

Frequently, you might want to create and operate on multiple domains at once. You can use functions like store.NewDomains or store.Repeat to create a slice of related domains. In the code below, five domains are created, each containing the values 1 through 10.

s3 := store.New()
d := store.Repeat(s3, 5, model.NewDomain(model.NewRange(1, 10)))
Copy

The store.Domains type has many of the same methods as the store.Domain type. Change methods require a domain index to modify as their first argument.

s4 := s3.Apply(
	d.Add(0, 42),
	d.Assign(2, 5),
	d.Remove(4, []int{2, 3, 4}),
)
if err := enc.Encode(s4); err != nil {
	panic(err)
}
Copy

Here is the complete main.go program.

package main

import (
	"encoding/json"
	"os"

	"github.com/nextmv-io/sdk/model"
	"github.com/nextmv-io/sdk/store"
)

func main() {
	s1 := store.New()
	d1 := store.NewDomain(s1)
	d2 := store.NewDomain(s1, model.NewRange(1, 10))
	d3 := store.NewDomain(s1, model.NewRange(-5, 5), model.NewRange(15, 25))

	s2 := s1.Apply(
		d1.Add(42, 43, 44),
		d2.Remove([]int{2, 4, 6, 8, 10}),
		d3.AtLeast(10),
	)

	enc := json.NewEncoder(os.Stdout)
	if err := enc.Encode(s2); err != nil {
		panic(err)
	}

	s3 := store.New()
	d := store.Repeat(s3, 5, model.NewDomain(model.NewRange(1, 10)))

	s4 := s3.Apply(
		d.Add(0, 42),
		d.Assign(2, 5),
		d.Remove(4, []int{2, 3, 4}),
	)
	if err := enc.Encode(s4); err != nil {
		panic(err)
	}
}
Copy

Run this source and you should see the domains described above.

[[[42,44]],[1,3,5,7,9],[[15,25]]]
[[[[1,10],42],[[1,10]],5,[[1,10]],[1,[5,10]]]]
Copy

There are many methods available on domains. Some allow you to modify them, while others help you select an individual domain from a collection of them. Take a look at the store and model Go package documentation to see what domains have to offer.

Exercises - domains

  • Create a domain on a store and a domain unattached to a store. Modify both of these domains in various ways.
  • Create multiple distinct domains on a store. Use a selector method, like Smallest or Largest to select an individual domain by index. Assign that domain a value.
  • Create a collection of domains each containing more than one value. Remove values from the domains until calling Singleton returns true.

Input and output

You are ready to start building models that actually do something. But first, you need to take a quick detour to understand how runners let you read input data into a model and output formatted JSON data to make decisions.

A runner is responsible for reading input data, setting up the solver execution environment, and writing output to a desired location. Runners make it easy to switch between different environments which may need to read data from different places or handle timeouts differently. They are the key to writing model code locally with confidence that the model with behave the same in production.

The NEXTMV_RUNNER environment variable defines the type of runner used.

  • "cli": (Default) Command Line Interface runner. Useful for running from a terminal. Can read from a file or stdin and write to a file or stdout.
  • "http": HTTP runner. Useful for sending requests and receiving responses on the specified port.

If the NEXTMV_RUNNER environment variable is not provided, the default "cli" value is used.

The code below creates an empty store and passes it to a runner. Technically this is a model, though it doesn't do anything. What it does do is let you see the whole process of reading data, building a model, and solving that model.

package main

import (
	"github.com/nextmv-io/sdk/run"
	"github.com/nextmv-io/sdk/store"
)

func main() {
	run.Run(
		func(input any, opt store.Options) (store.Solver, error) {
			return store.New().Satisfier(opt), nil
		},
	)
}
Copy

Most decision models begin with a call to run.Run in a main function. run.Run requires a handler. A handler reads data of any JSON-unmarshallable type, pulls solver options out of the environment or command line, and constructs a solver. The runner unmarshals the input for you and knows what to do with the solver.

In the handler here, it is not important what type the input data is, so you can label it as any. Usually you would use something like n int or x foo, where foo can be any structure that follows Go's JSON decoding rules.

To run this empty source you have several options, as you can read from a file or stdin. Similarly, you can write to a file or to stdout.

For example, you can read the number 42 from stdin, use the Nextmv CLI to run, write to stdout, and use jq to make the output look nice.

tour$ echo 42 | nextmv run local input-output/main.go -- \
    -hop.solver.limits.duration 5s \
    -hop.solver.diagram.expansion.limit 1 | jq .
Copy
{
  "options": {
    "diagram": {
      "expansion": {
        "limit": 1
      },
      "width": 10
    },
    "limits": {
      "duration": "5s"
    },
    "search": {
      "buffer": 100
    },
    "sense": "satisfy"
  },
  "statistics": {
    "search": {
      "deferred": 0,
      "expanded": 0,
      "explored": 0,
      "filtered": 0,
      "generated": 0,
      "reduced": 0,
      "restricted": 0,
      "solutions": 1
    },
    "time": {
      "elapsed": "\u003c\u003cPRESENCE\u003e\u003e",
      "elapsed_seconds": "\u003c\u003cPRESENCE\u003e\u003e",
      "start": "\u003c\u003cPRESENCE\u003e\u003e"
    }
  },
  "store": [],
  "version": {
    "sdk": "\u003c\u003cPRESENCE\u003e\u003e"
  }
}
Copy

Note that transient fields are represented with "\u003c\u003cPRESENCE\u003e\u003e", which is the unicode representation of "<<PRESENCE>>", due to their dynamic nature: every time the input is run, these fields will have a different value. This representation is compliant with the jsonassert package.

You can also store the number 42 in an input.json file to read from, and specify to the runner that you would like to write to a file named output.json.

tour$ nextmv run local input-output/main.go -- \
    -hop.runner.input.path input-output/input.json \
    -hop.runner.output.path input-output/output.json \
    -hop.solver.limits.duration 5s \
    -hop.solver.diagram.expansion.limit 1
Copy

The runner also accepts a wealth of command line flags and environment variables. You can see these by passing the -h flag.

tour$ nextmv run local input-output/main.go -- -h
Copy

Some flags and variables are specific to the runner. By default, the CLI runner is provided, though runners for methods like HTTP are also available. You can use command-line flags or environment variables. When using environment variables, use all caps and snake case. For example, using the command-line flag -hop.solver.limits.duration is equivalent to setting the environment variable HOP_SOLVER_LIMITS_DURATION.

You can read more about the available flags in the Nextmv CLI reference.

Exercises - input and output

  • Run the model with -h. What are the most useful flags to you?
  • Try setting the solutions output flag to all and last. How does this change the resulting JSON?

Running solvers directly

There may be situations in which a Nextmv runner is not needed, and you want to call the solver directly to process the solutions. The Solver has two methods for getting a Solution:

  • All: gets all the solutions found by the solver. Useful for understanding the progression of the search.
  • Last: gets the last (best) solution found by the solver.

Consider the same code we showed for processing input and output. We taught you how to use the CLI runner to process some input and output the -empty- solution. In the following snippets you can see how to get the solution from the solver directly (and printing it, for example), as opposed to using a runner.

package main

import (
	"context"
	"encoding/json"
	"fmt"

	"github.com/nextmv-io/sdk/store"
)

func main() {
	solver := store.New().Satisfier(store.DefaultOptions())
	all := solver.All(context.Background())

	// Loop over the channel values to get the solutions.
	solutions := make([]store.Store, len(all))
	for solution := range all {
		solutions = append(solutions, solution.Store)
	}

	// Print the solutions.
	b, err := json.Marshal(solutions)
	if err != nil {
		panic(err)
	}
	fmt.Println(string(b))
}
Copy

Running the above code snippets, you can see an empty output being printed.

tour$ nextmv run local input-output-all/main.go
[[]]
Copy

Note that the code snippets use the store.DefaultOptions which provide sensible defaults for solving.

Exercises - running solvers directly

  • Complete this tutorial and use a solver that actually does something. What happens when using All and modifying the Limits on Options to return fewer solutions?
  • Compare the last solution when using All, is it the same as the only solution returned when using Last?

Modeling

The Generate method is possibly the most powerful one, as it allows you to define the guardrails to generate new stores from existing ones. This creates a search tree that the solver uses to find the best operationally valid store.

A store is operationally valid if all decisions have been made and those decisions fulfill certain requirements; e.g.: all stops have been assigned to vehicles, all shifts are covered with the necessary personnel, all assignment have been made, quantity respects an alloted capacity, etc.

For example, you can search for all permutations of the positive integer numbers that go up to a specific number:

1 -> [
 [1]
]
2 -> [
 [1,2],
 [2,1]
]
3 -> [
 [1,2,3]
 [1,3,2]
 [2,1,3]
 [2,3,1]
 [3,1,2]
 [3,2,1]  
]
Copy

You can see that as the number increases, this search becomes non-trivial. Starting with an integer input n, define the root store and a domain of the unused integers:

root := store.New()
unused := store.NewDomain(root, model.NewRange(1, n))
Copy

You can use a slice to store the permutations as you search for them.

permutation := store.NewSlice[int](root)
Copy

The store is operationally valid if all permutations have been found, i.e.: the unused domain is empty.

root = root.Validate(unused.Empty)
Copy

For a simple output format, you can visualize the permutation slice.

root = root.Format(
	func(s store.Store) any {
		return permutation.Slice(s)
	},
)
Copy

From an existing parent store, you must define the rules to generate child stores. You can do so through an Eager or Lazy generator. In the following code snippet, we show you how to lazily generate new child stores by getting the values that haven't been used for a parent store. For each unused value, we append it to the permutations and remove it from the unused domain.

root = root.Generate(func(s store.Store) store.Generator {
	values := unused.Slice(s)
	return store.Lazy(
		func() bool { return len(values) > 0 },
		func() store.Store {
			next := values[0]
			values = values[1:]

			return s.Apply(
				unused.Remove([]int{next}),
				permutation.Append(next),
			)
		},
	)
})
Copy

The Lazy generator will generate new stores on demand when the solver needs them. On the other hand, an Eager generator will generate all children stores upfront, consuming more memory. In the following snippet you can find the same implementation already described but eagerly generating new child stores.

	root = root.Generate(func(s store.Store) store.Generator {
		values := unused.Slice(s)
		stores := make([]store.Store, unused.Len(s))
		for v, val := range values {
			stores[v] = s.Apply(
				unused.Remove([]int{val}),
				permutation.Append(val),
			)
		}
		return store.Eager(stores...)
	})

	return root.Satisfier(opt), nil
}
Copy

Given that you only care about finding all permutations, it is sufficient to satisfy operational validity by summoning a Satisfier, which is a type of Solver.

return root.Satisfier(opt), nil
Copy

When seeking to maximize or minimize a value, you can use the Value method on the store to define its value and the Maximizer or Minimizer solver, respectively.

Here are the complete sources for the Eager and Lazy variations, respectively.

package main

import (
	"errors"

	"github.com/nextmv-io/sdk/model"
	"github.com/nextmv-io/sdk/run"
	"github.com/nextmv-io/sdk/store"
)

func main() {
	run.Run(handler)
}

func handler(n int, opt store.Options) (store.Solver, error) {
	if n < 1 {
		return nil, errors.New("input must be > 1")
	}

	root := store.New()
	unused := store.NewDomain(root, model.NewRange(1, n))
	permutation := store.NewSlice[int](root)

	root = root.Validate(unused.Empty)
	root = root.Format(
		func(s store.Store) any {
			return permutation.Slice(s)
		},
	)
	root = root.Generate(func(s store.Store) store.Generator {
		values := unused.Slice(s)
		stores := make([]store.Store, unused.Len(s))
		for v, val := range values {
			stores[v] = s.Apply(
				unused.Remove([]int{val}),
				permutation.Append(val),
			)
		}
		return store.Eager(stores...)
	})

	return root.Satisfier(opt), nil
}
Copy

For n = 3, you can run the following command and one of the sources to observe the corresponding result. Please note that the input is piped via stdin and the result is saved to an output.json file.

tour$ echo 3 | \
  nextmv run local searching/main.go -- -hop.runner.output.solutions all \
                                      -hop.runner.output.path searching/output.json \
                                      -hop.solver.limits.duration 5s
                                      
Copy

Note that the -hop.runner.output.solutions flag is set to all, to gather all operationally valid solutions. By default, only the last solution is shown.

You can use jq to go into the .solutions key and look for all the .store keys.

tour$ cat searching/output.json | jq ".solutions | .[].store" -c
[1,2,3]
[1,3,2]
[2,1,3]
[2,3,1]
[3,1,2]
[3,2,1]
Copy

In some models, it is useful to propagate constraints to child stores. For example, in the case of sudoku, if you find one cell with one value, you can remove that value as an option from the other cells in the same row, column, or square. For models requiring constraint propagation you can make use of the Propagate method, as seen on our sudoku and shift scheduling templates.

Exercises - modeling

  • Run the tutorial for different values of n, such as 4.
  • Run the tutorial without specifying the -hop.runner.output.solutions flag. What is the last solution that the satisfier found?
  • Use Value and Minimizer to look for the permutation that has the smallest absolute distance between its numbers, i.e.: the permutation [1,3,4,2] has a distance of |3-1|+|4-3]+|2-4| = 2+1+2 = 5. On the other hand, the permutation [1,2,3,4] has a distance of 3.
  • Use Value and Maximizer to look for the permutation that has the largest absolute distance between its numbers.

๐ŸŽ‰ ๐ŸŽ‰ ๐ŸŽ‰ ๐ŸŽ‰

You have successfully completed the tour of SDK and store! Please see our other tutorials for complete walkthroughs and examples of more advanced search problems.

Page last updated

Go to on-page nav menu