├── .gitignore ├── meta ├── logo.png ├── matching.png └── logo.svg ├── pkg ├── validate │ ├── doc.go │ ├── array.go │ ├── single_table.go │ ├── double_table.go │ ├── single_table_test.go │ └── double_table_test.go ├── version │ ├── version_test.go │ └── version.go ├── core │ ├── algorithm_context.go │ ├── match_preference.go │ ├── match_result_test.go │ ├── match_result.go │ ├── doc.go │ ├── preference_list.go │ ├── test.go │ ├── preference_list_test.go │ ├── preference_table.go │ ├── member.go │ ├── member_test.go │ └── preference_table_test.go ├── algo │ ├── srp │ │ ├── srp.go │ │ ├── rejection.go │ │ ├── rejection_test.go │ │ ├── srp_test.go │ │ ├── cyclical_elimination_test.go │ │ ├── proposal.go │ │ ├── cyclical_elimination.go │ │ ├── proposal_test.go │ │ └── doc.go │ └── smp │ │ ├── smp.go │ │ ├── proposal.go │ │ ├── doc.go │ │ ├── smp_test.go │ │ └── proposal_test.go └── load │ ├── load_test.go │ └── load.go ├── scripts ├── benchmark.sh ├── test.sh ├── lint.sh └── build.sh ├── internal ├── config │ ├── flags.go │ ├── config.go │ └── config_test.go └── commands │ ├── ls_test.go │ ├── ls.go │ ├── solve.go │ └── solve_test.go ├── go.mod ├── Makefile ├── .github └── workflows │ ├── test.yml │ └── build_release.yml ├── LICENSE ├── cmd └── libmatch │ ├── main_test.go │ └── main.go ├── go.sum ├── benchmark_test.go ├── libmatch.go ├── README.md └── libmatch_test.go /.gitignore: -------------------------------------------------------------------------------- 1 | /libmatch 2 | test.json 3 | -------------------------------------------------------------------------------- /meta/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/abhchand/libmatch/HEAD/meta/logo.png -------------------------------------------------------------------------------- /meta/matching.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/abhchand/libmatch/HEAD/meta/matching.png -------------------------------------------------------------------------------- /pkg/validate/doc.go: -------------------------------------------------------------------------------- 1 | /* 2 | Package validate provides helpers to validate preference data 3 | */ 4 | package validate 5 | -------------------------------------------------------------------------------- /scripts/benchmark.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | # 4 | # Run all benchmarks 5 | # 6 | 7 | set -e 8 | 9 | go test -bench=. 10 | -------------------------------------------------------------------------------- /internal/config/flags.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "github.com/urfave/cli/v2" 5 | ) 6 | 7 | // Flags available globally, with all commands 8 | var GlobalFlags = []cli.Flag{} 9 | -------------------------------------------------------------------------------- /scripts/test.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | # 4 | # Run all tests 5 | # 6 | 7 | set -e 8 | 9 | RED='\033[00;31m' 10 | GREEN='\033[00;32m' 11 | RESTORE='\033[0m' 12 | 13 | if go test ./...; then 14 | echo -e "${GREEN}OK${RESTORE}" 15 | else 16 | echo -e "${RED}FAIL${RESTORE}" 17 | exit 1 18 | fi 19 | -------------------------------------------------------------------------------- /pkg/version/version_test.go: -------------------------------------------------------------------------------- 1 | package version 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | ) 8 | 9 | func TestFormatted(t *testing.T) { 10 | assert.Equal(t, "v0.1.0", Formatted()) 11 | } 12 | 13 | func TestVersion(t *testing.T) { 14 | assert.Equal(t, "0.1.0", Version()) 15 | } 16 | -------------------------------------------------------------------------------- /scripts/lint.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | # 4 | # Lint all files 5 | # 6 | 7 | set -e 8 | 9 | RED='\033[00;31m' 10 | GREEN='\033[00;32m' 11 | RESTORE='\033[0m' 12 | 13 | if [ "$(gofmt -s -l . | tee /dev/stderr | wc -l)" -gt 0 ]; then 14 | echo -e "${RED}FAIL${RESTORE}" 15 | exit 1 16 | else 17 | echo -e "${GREEN}OK${RESTORE}" 18 | fi 19 | -------------------------------------------------------------------------------- /pkg/validate/array.go: -------------------------------------------------------------------------------- 1 | package validate 2 | 3 | import ( 4 | "sort" 5 | ) 6 | 7 | func stringSlicesMatch(a, b []string) bool { 8 | sort.Strings(a) 9 | sort.Strings(b) 10 | 11 | if len(a) != len(b) { 12 | return false 13 | } 14 | 15 | for i := range a { 16 | if a[i] != b[i] { 17 | return false 18 | } 19 | } 20 | 21 | return true 22 | } 23 | -------------------------------------------------------------------------------- /pkg/core/algorithm_context.go: -------------------------------------------------------------------------------- 1 | package core 2 | 3 | // AlgorithmContext contains the information required to run an Algorithm 4 | // 5 | // Not all algorithms may require every fields in this struct. It is designed 6 | // to be as generally applicable as possible. 7 | type AlgorithmContext struct { 8 | TableA *PreferenceTable 9 | TableB *PreferenceTable 10 | } 11 | -------------------------------------------------------------------------------- /pkg/core/match_preference.go: -------------------------------------------------------------------------------- 1 | package core 2 | 3 | // MatchPreference stores information about a member and their preference list 4 | // of other members. 5 | // 6 | // This data model is designed to be a container for information read directly 7 | // from a stream of JSON data (e.g. a file on disk). 8 | type MatchPreference struct { 9 | Name string `json:"name"` 10 | Preferences []string `json:"preferences"` 11 | } 12 | -------------------------------------------------------------------------------- /internal/commands/ls_test.go: -------------------------------------------------------------------------------- 1 | package commands 2 | 3 | import ( 4 | "flag" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/assert" 8 | "github.com/urfave/cli/v2" 9 | ) 10 | 11 | func TestLsAction(t *testing.T) { 12 | globalSet := flag.NewFlagSet("test", 0) 13 | globalSet.Bool("debug", false, "doc") 14 | 15 | app := cli.NewApp() 16 | ctx := cli.NewContext(app, globalSet, nil) 17 | err := lsAction(ctx) 18 | 19 | assert.Nil(t, err) 20 | } 21 | -------------------------------------------------------------------------------- /pkg/version/version.go: -------------------------------------------------------------------------------- 1 | // Package version stores version information. 2 | package version 3 | 4 | import ( 5 | "fmt" 6 | ) 7 | 8 | // The build script parses the version from this line. 9 | // Check the regex in `build.sh` before modifying this! 10 | var version = "0.1.0" 11 | 12 | // Formatted formats the version as a printable string. 13 | func Formatted() string { 14 | return fmt.Sprintf("v%v", version) 15 | } 16 | 17 | // Version returns the current version. 18 | func Version() string { 19 | return version 20 | } 21 | -------------------------------------------------------------------------------- /pkg/core/match_result_test.go: -------------------------------------------------------------------------------- 1 | package core 2 | 3 | func ExamplePrint__format_csv() { 4 | res := MatchResult{ 5 | Mapping: map[string]string{ 6 | "A": "B", 7 | "B": "A", 8 | }, 9 | } 10 | 11 | res.Print("csv") 12 | // Unordered output: 13 | // A,B 14 | // B,A 15 | } 16 | 17 | func ExamplePrint__format_json() { 18 | res := MatchResult{ 19 | Mapping: map[string]string{ 20 | "A": "B", 21 | "B": "A", 22 | }, 23 | } 24 | 25 | res.Print("json") 26 | // Unordered output: 27 | // {"mapping":{"A":"B","B":"A"}} 28 | } 29 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/abhchand/libmatch 2 | 3 | go 1.17 4 | 5 | require ( 6 | github.com/stretchr/testify v1.7.0 7 | github.com/urfave/cli/v2 v2.3.0 8 | ) 9 | 10 | require ( 11 | github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d // indirect 12 | github.com/davecgh/go-spew v1.1.0 // indirect 13 | github.com/pmezard/go-difflib v1.0.0 // indirect 14 | github.com/russross/blackfriday/v2 v2.0.1 // indirect 15 | github.com/shurcooL/sanitized_anchor_name v1.0.0 // indirect 16 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c // indirect 17 | ) 18 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .SILENT: ; # no need for @ 2 | .ONESHELL: ; # recipes execute in same shell 3 | .NOTPARALLEL: ; # wait for target to finish 4 | .EXPORT_ALL_VARIABLES: ; # send all vars to shell 5 | 6 | BINARY_NAME=libmatch 7 | 8 | export GO111MODULE=on 9 | 10 | all: lint test build 11 | 12 | benchmark: 13 | echo '# $@' 14 | scripts/benchmark.sh 15 | echo '' 16 | 17 | build: 18 | echo '# $@' 19 | rm -f $(BINARY_NAME) 20 | scripts/build.sh $(BINARY_NAME) 21 | echo '' 22 | 23 | lint: 24 | echo '# $@' 25 | scripts/lint.sh 26 | echo '' 27 | 28 | lintfix: 29 | echo '# $@' 30 | gofmt -l -w -s . 31 | echo '' 32 | 33 | test: 34 | echo '# $@' 35 | scripts/test.sh 36 | echo '' 37 | -------------------------------------------------------------------------------- /pkg/algo/srp/srp.go: -------------------------------------------------------------------------------- 1 | package srp 2 | 3 | import ( 4 | "errors" 5 | "github.com/abhchand/libmatch/pkg/core" 6 | ) 7 | 8 | // Run solves the "Stable Roommates Problem" (SRP) for a set of given inputs. 9 | // 10 | // See srp package documentation for an end-to-end example 11 | func Run(algoCtx core.AlgorithmContext) (core.MatchResult, error) { 12 | var res core.MatchResult 13 | pt := algoCtx.TableA 14 | 15 | if !phase1Proposal(pt) { 16 | return res, errors.New("No stable solution exists") 17 | } 18 | 19 | phase2Rejection(pt) 20 | phase3CyclicalElimnation(pt) 21 | 22 | res.Mapping = make(map[string]string) 23 | for name, member := range *pt { 24 | res.Mapping[name] = member.PreferenceList().Members()[0].Name() 25 | } 26 | 27 | return res, nil 28 | } 29 | -------------------------------------------------------------------------------- /pkg/core/match_result.go: -------------------------------------------------------------------------------- 1 | package core 2 | 3 | import ( 4 | "encoding/json" 5 | "errors" 6 | "fmt" 7 | ) 8 | 9 | // MatchResult stores the result of executing a mapping algorithm 10 | type MatchResult struct { 11 | Mapping map[string]string `json:"mapping"` 12 | } 13 | 14 | // Print prints formatted match results in a sepcified format. The `format` can 15 | // be specified as one of the following: 16 | // * csv 17 | // * json 18 | func (mr MatchResult) Print(format string) error { 19 | switch format { 20 | case "csv": 21 | for a, b := range mr.Mapping { 22 | fmt.Printf("%v,%v\n", a, b) 23 | } 24 | case "json": 25 | json, _ := json.Marshal(mr) 26 | fmt.Println(string(json)) 27 | default: 28 | return errors.New(fmt.Sprintf("Unknown format '%v'", format)) 29 | } 30 | 31 | return nil 32 | } 33 | -------------------------------------------------------------------------------- /pkg/algo/smp/smp.go: -------------------------------------------------------------------------------- 1 | package smp 2 | 3 | import ( 4 | "github.com/abhchand/libmatch/pkg/core" 5 | ) 6 | 7 | // Run executes the algorithm to solve the "Stable Marriage Problem" (SMP) for 8 | // a set of given preference inputs. 9 | // 10 | // See smp package documentation for more detail 11 | func Run(algoCtx core.AlgorithmContext) (core.MatchResult, error) { 12 | ptA := algoCtx.TableA 13 | ptB := algoCtx.TableB 14 | 15 | phase1Proposal(ptA, ptB) 16 | 17 | return buildResult(ptA, ptB), nil 18 | } 19 | 20 | // buildResult constructs a Match Result from a Preference Table that has 21 | // been reduced by the algorithm run. 22 | func buildResult(ptA, ptB *core.PreferenceTable) core.MatchResult { 23 | res := core.MatchResult{} 24 | 25 | res.Mapping = make(map[string]string) 26 | 27 | for _, pt := range []*core.PreferenceTable{ptA, ptB} { 28 | for name, member := range *pt { 29 | res.Mapping[name] = member.CurrentProposer().Name() 30 | } 31 | } 32 | 33 | return res 34 | } 35 | -------------------------------------------------------------------------------- /pkg/algo/srp/rejection.go: -------------------------------------------------------------------------------- 1 | package srp 2 | 3 | import ( 4 | "github.com/abhchand/libmatch/pkg/core" 5 | ) 6 | 7 | // phase2Rejection implements the 2nd phase of the Irving (1985) algorithm to 8 | // solve the "Stable Roommate Problem". 9 | // 10 | // Each member that has accepted a proposal will remove those they prefer less 11 | // than their current proposer. 12 | // 13 | // See srp package documentation for more detail 14 | func phase2Rejection(pt *core.PreferenceTable) { 15 | for _, member := range *pt { 16 | idx := -1 17 | prefs := member.PreferenceList().Members() 18 | 19 | // Find the index of the current proposer 20 | for i := range prefs { 21 | if prefs[i].Name() == member.CurrentProposer().Name() { 22 | idx = i 23 | break 24 | } 25 | } 26 | 27 | // Reject all members less preferred than the current proposer 28 | membersToReject := prefs[(idx + 1):] 29 | for i := range membersToReject { 30 | member.Reject(membersToReject[i]) 31 | } 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /pkg/core/doc.go: -------------------------------------------------------------------------------- 1 | /* 2 | Package core implements the common data models to execute matching algorithms 3 | 4 | DATA MODEL 5 | 6 | Most matching algorithm rely on the following shared concept: 7 | 8 | - A "member" is an individual element that is to be matched with another member. 9 | - Each member typically has a "preference list" containing an ordered list of 10 | other members it prefers to be matched with. 11 | - A mapping of all members to their preference lists is called a "preference 12 | table" 13 | 14 | A preference table can be represented as follows: 15 | 16 | A => [B, D, F, C, E] 17 | B => [D, E, F, A, C] 18 | C => [D, E, F, A, B] 19 | D => [F, C, A, E, B] 20 | E => [F, C, D, B, A] 21 | F => [A, B, D, C, E] 22 | 23 | Most algorithms implement an iterative process to reduce each member's preference 24 | list by eliminating less preferred options. 25 | 26 | The resulting mapping is the mathematically "stable" solution, where no two 27 | members would prefer each other more than their existing matches. 28 | 29 | A => F 30 | B => E 31 | C => D 32 | D => C 33 | E => B 34 | F => A 35 | */ 36 | package core 37 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Run Tests 2 | 3 | on: 4 | push: 5 | branches: 6 | - '*' 7 | tags-ignore: 8 | - 'v*' 9 | 10 | jobs: 11 | lint: 12 | name: lint 13 | runs-on: ubuntu-latest 14 | steps: 15 | - name: Set up Go 1.17 16 | uses: actions/setup-go@v2 17 | with: 18 | go-version: ${{ matrix.go-version }} 19 | id: go 20 | - name: Check out code 21 | uses: actions/checkout@v2 22 | - name: Run linters 23 | run: make lint 24 | 25 | test: 26 | name: test 27 | strategy: 28 | matrix: 29 | go-version: [1.17.x] 30 | platform: [ubuntu-latest, macos-latest] 31 | runs-on: ${{ matrix.platform }} 32 | steps: 33 | - name: Set up Go 1.17 34 | uses: actions/setup-go@v2 35 | with: 36 | go-version: ${{ matrix.go-version }} 37 | id: go 38 | - name: Check out code 39 | uses: actions/checkout@v2 40 | - name: Run Go tests 41 | run: make test 42 | - name: Run Go benchmarks 43 | run: "echo \"Platform: ${{ matrix.platform }}\" && go test -bench=." 44 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Abhishek Chandrasekhar 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | 23 | -------------------------------------------------------------------------------- /.github/workflows/build_release.yml: -------------------------------------------------------------------------------- 1 | name: Build and Release 2 | 3 | on: 4 | push: 5 | branches-ignore: 6 | - '*' 7 | tags: 8 | - 'v*' 9 | 10 | jobs: 11 | setup-release: 12 | name: create release 13 | runs-on: ubuntu-latest 14 | steps: 15 | - name: Create Github Release 16 | run: | 17 | curl \ 18 | -H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \ 19 | -X POST \ 20 | -H "Accept: application/vnd.github.v3+json" \ 21 | https://api.github.com/repos/abhchand/libmatch/releases \ 22 | -d '{"tag_name":"${{ github.ref_name }}"}' 23 | 24 | releases-matrix: 25 | name: Build+Release Go Binary 26 | runs-on: ubuntu-latest 27 | strategy: 28 | matrix: 29 | goos: [linux, darwin] 30 | goarch: ['386', amd64, arm64] 31 | exclude: 32 | - goarch: '386' 33 | goos: darwin 34 | steps: 35 | - uses: actions/checkout@v2 36 | - uses: wangyoucao577/go-release-action@v1.24 37 | with: 38 | github_token: ${{ secrets.GITHUB_TOKEN }} 39 | goos: ${{ matrix.goos }} 40 | goarch: ${{ matrix.goarch }} 41 | goversion: 1.17 42 | build_command: make build 43 | -------------------------------------------------------------------------------- /internal/config/config.go: -------------------------------------------------------------------------------- 1 | // Package config provides accessors to create and model internal libmatch 2 | // configuration 3 | package config 4 | 5 | import ( 6 | "path/filepath" 7 | "strings" 8 | 9 | "github.com/urfave/cli/v2" 10 | ) 11 | 12 | // Config defines the structure of the internal libmatch configuration 13 | type Config struct { 14 | Algorithm string 15 | Debug bool 16 | Filenames []string 17 | OutputFormat string 18 | CliContext *cli.Context 19 | } 20 | 21 | // NewConfig returns a new Config structure 22 | func NewConfig(ctx *cli.Context) (*Config, error) { 23 | cfg := &Config{ 24 | Algorithm: strings.ToUpper(ctx.String("algorithm")), 25 | Debug: ctx.Bool("debug"), 26 | OutputFormat: ctx.String("format"), 27 | CliContext: ctx, 28 | } 29 | 30 | // Expand path of each `file` flag 31 | expandedFiles, err := expandFilenames(cfg.CliContext.StringSlice("file")) 32 | if err != nil { 33 | return cfg, err 34 | } 35 | cfg.Filenames = expandedFiles 36 | 37 | return cfg, nil 38 | } 39 | 40 | // expandFilenames expands all relative filenames into absolute paths 41 | func expandFilenames(files []string) ([]string, error) { 42 | for f := range files { 43 | absFilename, err := filepath.Abs(files[f]) 44 | 45 | if err != nil { 46 | return files, err 47 | } 48 | 49 | files[f] = absFilename 50 | } 51 | 52 | return files, nil 53 | } 54 | -------------------------------------------------------------------------------- /pkg/core/preference_list.go: -------------------------------------------------------------------------------- 1 | package core 2 | 3 | import ( 4 | "strings" 5 | ) 6 | 7 | // PreferenceList models an ordered list of preferences for other members for 8 | // any given Member. 9 | type PreferenceList struct { 10 | members []*Member 11 | } 12 | 13 | // NewPreferenceList returns a new preference list given an array of initial 14 | // ordered members. 15 | func NewPreferenceList(members []*Member) PreferenceList { 16 | return PreferenceList{members: members} 17 | } 18 | 19 | // String returns a human readable representation of this preference list 20 | func (pl PreferenceList) String() string { 21 | names := make([]string, len(pl.members)) 22 | 23 | for i := range pl.members { 24 | names[i] = pl.members[i].String() 25 | } 26 | 27 | return strings.Join(names, ", ") 28 | } 29 | 30 | // Members returns the raw list of preferred members 31 | func (pl PreferenceList) Members() []*Member { 32 | return pl.members 33 | } 34 | 35 | // Remove removes a specific member from the preference list 36 | func (pl *PreferenceList) Remove(member Member) { 37 | idx := -1 38 | 39 | // Find index of `member` 40 | for m := range pl.members { 41 | if member.Name() == pl.members[m].Name() { 42 | idx = m 43 | break 44 | } 45 | } 46 | 47 | if idx == -1 { 48 | return 49 | } 50 | 51 | // Remove `member` 52 | newMembers := make([]*Member, len(pl.members)-1) 53 | copy(newMembers[:idx], pl.members[:idx]) 54 | copy(newMembers[idx:], pl.members[idx+1:]) 55 | 56 | pl.members = newMembers 57 | } 58 | -------------------------------------------------------------------------------- /pkg/algo/srp/rejection_test.go: -------------------------------------------------------------------------------- 1 | package srp 2 | 3 | import ( 4 | "reflect" 5 | "testing" 6 | 7 | "github.com/abhchand/libmatch/pkg/core" 8 | "github.com/stretchr/testify/assert" 9 | ) 10 | 11 | func TestPhase2Rejection(t *testing.T) { 12 | pt := core.NewPreferenceTable(&[]core.MatchPreference{ 13 | {Name: "A", Preferences: []string{"B", "D", "F", "C", "E"}}, 14 | {Name: "B", Preferences: []string{"E", "F", "A", "C"}}, 15 | {Name: "C", Preferences: []string{"D", "E", "F", "A", "B"}}, 16 | {Name: "D", Preferences: []string{"F", "C", "A", "E"}}, 17 | {Name: "E", Preferences: []string{"C", "D", "B", "A"}}, 18 | {Name: "F", Preferences: []string{"A", "B", "D", "C"}}, 19 | }) 20 | 21 | pt["A"].Accept(pt["F"]) 22 | pt["B"].Accept(pt["A"]) 23 | pt["C"].Accept(pt["E"]) 24 | pt["D"].Accept(pt["C"]) 25 | pt["E"].Accept(pt["B"]) 26 | pt["F"].Accept(pt["D"]) 27 | 28 | wanted := core.NewPreferenceTable(&[]core.MatchPreference{ 29 | {Name: "A", Preferences: []string{"B", "F"}}, 30 | {Name: "B", Preferences: []string{"E", "F", "A"}}, 31 | {Name: "C", Preferences: []string{"D", "E"}}, 32 | {Name: "D", Preferences: []string{"F", "C"}}, 33 | {Name: "E", Preferences: []string{"C", "B"}}, 34 | {Name: "F", Preferences: []string{"A", "B", "D"}}, 35 | }) 36 | 37 | wanted["A"].Accept(wanted["F"]) 38 | wanted["B"].Accept(wanted["A"]) 39 | wanted["C"].Accept(wanted["E"]) 40 | wanted["D"].Accept(wanted["C"]) 41 | wanted["E"].Accept(wanted["B"]) 42 | wanted["F"].Accept(wanted["D"]) 43 | 44 | phase2Rejection(&pt) 45 | 46 | assert.True(t, reflect.DeepEqual(wanted, pt)) 47 | } 48 | -------------------------------------------------------------------------------- /pkg/algo/smp/proposal.go: -------------------------------------------------------------------------------- 1 | package smp 2 | 3 | import ( 4 | "github.com/abhchand/libmatch/pkg/core" 5 | ) 6 | 7 | // phase1Proposal implements the 1st (and only) phase of the Gale-Shapley (1962) 8 | // algorithm to solve the "Stable Marriage Problem". 9 | // 10 | // Each unmatched member "proposes" to their top remaining preference and each 11 | // member that receives a proposal can accept or reject the incoming proposal. 12 | // 13 | // See smp package documentation for more detail 14 | func phase1Proposal(ptA, ptB *core.PreferenceTable) { 15 | for true { 16 | unmatchedMembers := ptA.UnmatchedMembers() 17 | 18 | if len(unmatchedMembers) == 0 { 19 | break 20 | } 21 | 22 | for i := range unmatchedMembers { 23 | member := unmatchedMembers[i] 24 | topChoice := member.FirstPreference() 25 | simulateProposal(member, topChoice) 26 | } 27 | } 28 | } 29 | 30 | // simulateProposal simulates a proposal between two members 31 | func simulateProposal(proposer, proposed *core.Member) { 32 | if !proposed.HasAcceptedProposal() { 33 | // Proposed member does not have a proposal. Blindly accept this one. 34 | proposed.AcceptMutually(proposer) 35 | } else if proposed.WouldPreferProposalFrom(*proposer) { 36 | // Proposed member has a proposal, but the new proposal is better. Reject 37 | // the existing proposal and accept this new one. 38 | proposed.RejectMutually(proposed.CurrentProposer()) 39 | proposed.AcceptMutually(proposer) 40 | } else { 41 | // Proposed member has a proposal, but prefers to hold on to it. Reject 42 | // this new proposal. 43 | proposed.RejectMutually(proposer) 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /cmd/libmatch/main_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bufio" 5 | "fmt" 6 | "os" 7 | ) 8 | 9 | var testFile = "/tmp/libmatch_test.json" 10 | 11 | func ExampleMain__solve_success() { 12 | body := ` 13 | [ 14 | { "name":"A", "preferences": ["B", "C", "D"] }, 15 | { "name":"B", "preferences": ["A", "C", "D"] }, 16 | { "name":"C", "preferences": ["A", "B", "D"] }, 17 | { "name":"D", "preferences": ["A", "B", "C"] } 18 | ] 19 | ` 20 | 21 | writeToFile(testFile, body) 22 | 23 | os.Args = []string{ 24 | "libmatch", "solve", "-a", "srp", "-o", "csv", "-f", testFile, 25 | } 26 | 27 | main() 28 | 29 | // Unordered output: 30 | // A,B 31 | // B,A 32 | // C,D 33 | // D,C 34 | } 35 | 36 | func ExampleMain__solve_error() { 37 | body := ` 38 | [ 39 | {"name":"A","preferences":["B","E","C","F","D"]}, 40 | {"name":"B","preferences":["C","F","E","A","D"]}, 41 | {"name":"C","preferences":["E","A","F","D","B"]}, 42 | {"name":"D","preferences":["B","A","C","F","E"]}, 43 | {"name":"E","preferences":["A","C","D","B","F"]}, 44 | {"name":"F","preferences":["C","A","E","B","D"]} 45 | ] 46 | 47 | ` 48 | 49 | writeToFile(testFile, body) 50 | 51 | os.Args = []string{ 52 | "libmatch", "solve", "-a", "srp", "-o", "csv", "-f", testFile, 53 | } 54 | 55 | main() 56 | 57 | // Output: 58 | // No stable solution exists 59 | } 60 | 61 | func writeToFile(filename, body string) { 62 | file, err := os.Create(filename) 63 | if err != nil { 64 | fmt.Println(err) 65 | } 66 | 67 | writer := bufio.NewWriter(file) 68 | 69 | _, err = writer.WriteString(body) 70 | if err != nil { 71 | fmt.Printf("Could not create file: %s\n", err.Error()) 72 | } 73 | 74 | writer.Flush() 75 | } 76 | -------------------------------------------------------------------------------- /pkg/algo/srp/srp_test.go: -------------------------------------------------------------------------------- 1 | package srp 2 | 3 | import ( 4 | "reflect" 5 | "testing" 6 | 7 | "github.com/abhchand/libmatch/pkg/core" 8 | "github.com/stretchr/testify/assert" 9 | ) 10 | 11 | func TestRun(t *testing.T) { 12 | t.Run("success", func(t *testing.T) { 13 | pt := core.NewPreferenceTable(&[]core.MatchPreference{ 14 | {Name: "A", Preferences: []string{"B", "C", "D"}}, 15 | {Name: "B", Preferences: []string{"A", "C", "D"}}, 16 | {Name: "C", Preferences: []string{"A", "B", "D"}}, 17 | {Name: "D", Preferences: []string{"A", "B", "C"}}, 18 | }) 19 | 20 | algoCtx := core.AlgorithmContext{ 21 | TableA: &pt, 22 | } 23 | 24 | wanted := core.MatchResult{ 25 | Mapping: map[string]string{ 26 | "A": "B", 27 | "B": "A", 28 | "C": "D", 29 | "D": "C", 30 | }, 31 | } 32 | 33 | result, err := Run(algoCtx) 34 | 35 | assert.Nil(t, err) 36 | assert.True(t, reflect.DeepEqual(wanted, result)) 37 | }) 38 | 39 | t.Run("no stable solution exists", func(t *testing.T) { 40 | /* 41 | * All other rooommates prefer "D" the least and prefer each other 42 | * with equal priority. In this case D's preference list will get 43 | * exhausted as no one prefers D to any other match 44 | */ 45 | pt := core.NewPreferenceTable(&[]core.MatchPreference{ 46 | {Name: "A", Preferences: []string{"B", "C", "D"}}, 47 | {Name: "B", Preferences: []string{"C", "A", "D"}}, 48 | {Name: "C", Preferences: []string{"A", "B", "D"}}, 49 | {Name: "D", Preferences: []string{"A", "B", "C"}}, 50 | }) 51 | 52 | algoCtx := core.AlgorithmContext{ 53 | TableA: &pt, 54 | } 55 | 56 | _, err := Run(algoCtx) 57 | 58 | assert.Equal(t, "No stable solution exists", err.Error()) 59 | }) 60 | } 61 | -------------------------------------------------------------------------------- /meta/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /pkg/algo/srp/cyclical_elimination_test.go: -------------------------------------------------------------------------------- 1 | package srp 2 | 3 | import ( 4 | "fmt" 5 | "reflect" 6 | "testing" 7 | 8 | "github.com/abhchand/libmatch/pkg/core" 9 | "github.com/stretchr/testify/assert" 10 | ) 11 | 12 | func TestPhase3CyclicalElimnation(t *testing.T) { 13 | wanted := core.NewPreferenceTable(&[]core.MatchPreference{ 14 | {Name: "A", Preferences: []string{"F"}}, 15 | {Name: "B", Preferences: []string{"E"}}, 16 | {Name: "C", Preferences: []string{"D"}}, 17 | {Name: "D", Preferences: []string{"C"}}, 18 | {Name: "E", Preferences: []string{"B"}}, 19 | {Name: "F", Preferences: []string{"A"}}, 20 | }) 21 | 22 | testCases := []string{"A", "B", "C", "D", "E", "F", ""} 23 | 24 | for tc := range testCases { 25 | title := fmt.Sprintf("With seed: %v", testCases[tc]) 26 | 27 | t.Run(title, func(t *testing.T) { 28 | pt := core.NewPreferenceTable(&[]core.MatchPreference{ 29 | {Name: "A", Preferences: []string{"B", "F"}}, 30 | {Name: "B", Preferences: []string{"E", "F", "A"}}, 31 | {Name: "C", Preferences: []string{"D", "E"}}, 32 | {Name: "D", Preferences: []string{"F", "C"}}, 33 | {Name: "E", Preferences: []string{"C", "B"}}, 34 | {Name: "F", Preferences: []string{"A", "B", "D"}}, 35 | }) 36 | 37 | phase3CyclicalElimnationWithSeed(&pt, testCases[tc]) 38 | 39 | assert.True(t, reflect.DeepEqual(wanted, pt)) 40 | }) 41 | } 42 | 43 | t.Run("table is already complete", func(t *testing.T) { 44 | pt := core.NewPreferenceTable(&[]core.MatchPreference{ 45 | {Name: "A", Preferences: []string{"F"}}, 46 | {Name: "B", Preferences: []string{"E"}}, 47 | {Name: "C", Preferences: []string{"D"}}, 48 | {Name: "D", Preferences: []string{"C"}}, 49 | {Name: "E", Preferences: []string{"B"}}, 50 | {Name: "F", Preferences: []string{"A"}}, 51 | }) 52 | 53 | phase3CyclicalElimnation(&pt) 54 | 55 | assert.True(t, reflect.DeepEqual(wanted, pt)) 56 | }) 57 | } 58 | -------------------------------------------------------------------------------- /pkg/core/test.go: -------------------------------------------------------------------------------- 1 | package core 2 | 3 | var memA, memB, memC, memD, memK, memL, memM Member 4 | var plA, plB, plC, plD, plK, plL, plM PreferenceList 5 | var pt, ptA, ptB PreferenceTable 6 | 7 | func setupSingleTable() { 8 | memA = Member{name: "A"} 9 | memB = Member{name: "B"} 10 | memC = Member{name: "C"} 11 | memD = Member{name: "D"} 12 | 13 | plA = PreferenceList{members: []*Member{&memB, &memC, &memD}} 14 | plB = PreferenceList{members: []*Member{&memA, &memC, &memD}} 15 | plC = PreferenceList{members: []*Member{&memA, &memB, &memD}} 16 | plD = PreferenceList{members: []*Member{&memA, &memB, &memC}} 17 | 18 | memA.SetPreferenceList(&plA) 19 | memB.SetPreferenceList(&plB) 20 | memC.SetPreferenceList(&plC) 21 | memD.SetPreferenceList(&plD) 22 | 23 | pt = PreferenceTable{ 24 | "A": &memA, 25 | "B": &memB, 26 | "C": &memC, 27 | "D": &memD, 28 | } 29 | } 30 | 31 | func setupDoubleTable() { 32 | memA = Member{name: "A"} 33 | memB = Member{name: "B"} 34 | memC = Member{name: "C"} 35 | 36 | memK = Member{name: "K"} 37 | memL = Member{name: "L"} 38 | memM = Member{name: "M"} 39 | 40 | plA = PreferenceList{members: []*Member{&memK, &memL, &memM}} 41 | plB = PreferenceList{members: []*Member{&memL, &memM, &memK}} 42 | plC = PreferenceList{members: []*Member{&memM, &memL, &memK}} 43 | 44 | plK = PreferenceList{members: []*Member{&memB, &memC, &memA}} 45 | plL = PreferenceList{members: []*Member{&memA, &memC, &memB}} 46 | plM = PreferenceList{members: []*Member{&memA, &memB, &memC}} 47 | 48 | memA.SetPreferenceList(&plA) 49 | memB.SetPreferenceList(&plB) 50 | memC.SetPreferenceList(&plC) 51 | 52 | memK.SetPreferenceList(&plK) 53 | memL.SetPreferenceList(&plL) 54 | memM.SetPreferenceList(&plM) 55 | 56 | ptA = PreferenceTable{ 57 | "A": &memA, 58 | "B": &memB, 59 | "C": &memC, 60 | } 61 | 62 | ptB = PreferenceTable{ 63 | "K": &memK, 64 | "L": &memL, 65 | "M": &memM, 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /cmd/libmatch/main.go: -------------------------------------------------------------------------------- 1 | // Package main is the entrypoint for the libmatch command line executable 2 | package main 3 | 4 | import ( 5 | "fmt" 6 | "os" 7 | "path/filepath" 8 | 9 | "github.com/abhchand/libmatch/internal/commands" 10 | "github.com/abhchand/libmatch/internal/config" 11 | "github.com/abhchand/libmatch/pkg/version" 12 | "github.com/urfave/cli/v2" 13 | ) 14 | 15 | func main() { 16 | 17 | app := cli.NewApp() 18 | app.Name = "libmatch" 19 | app.HelpName = filepath.Base(os.Args[0]) 20 | app.Usage = "A library for solving matching problems" 21 | app.Description = "For documentation, visit https://github.com/abhchand/libmatch#README" 22 | app.Version = version.Version() 23 | app.EnableBashCompletion = true 24 | app.Flags = config.GlobalFlags 25 | 26 | app.Commands = []*cli.Command{ 27 | commands.SolveCommand(), 28 | commands.LsCommand(), 29 | } 30 | 31 | // Customize the output of `-v` / `--version` 32 | cli.VersionPrinter = func(c *cli.Context) { 33 | fmt.Fprintf(c.App.Writer, "%v\n", version.Formatted()) 34 | } 35 | 36 | // Customize Application help-text 37 | cli.AppHelpTemplate = ` 38 | Usage: {{.HelpName}} [GLOBAL OPTIONS] COMMAND [OPTIONS] 39 | 40 | {{.Usage}} 41 | https://github.com/abhchand/libmatch#README 42 | 43 | {{if .Commands}} 44 | COMMANDS: 45 | {{range .Commands}}{{if not .HideHelp}} {{join .Names ", "}}{{ "\t"}}{{.Usage}}{{ "\n" }}{{end}}{{end}}{{end}}{{if .VisibleFlags}} 46 | GLOBAL OPTIONS: 47 | {{range .VisibleFlags}}{{.}} 48 | {{end}}{{end}} 49 | 50 | Run 'libmatch COMMAND --help' for more information on a command. 51 | ` 52 | 53 | // Customize command help-text 54 | cli.CommandHelpTemplate = `Usage: {{.HelpName}} [GLOBAL OPTIONS] COMMAND [OPTIONS] 55 | 56 | {{.Usage}} 57 | {{if .VisibleFlags}} 58 | OPTIONS: 59 | {{range .VisibleFlags}}{{.}} 60 | {{end}}{{end}} 61 | ` 62 | 63 | // Start the CLI 64 | if err := app.Run(os.Args); err != nil { 65 | fmt.Println(err) 66 | } 67 | 68 | } 69 | -------------------------------------------------------------------------------- /internal/commands/ls.go: -------------------------------------------------------------------------------- 1 | package commands 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | 7 | "github.com/urfave/cli/v2" 8 | ) 9 | 10 | var SEQ_BOLD = "\033[1m" 11 | var SEQ_RESET = "\033[0m" 12 | 13 | var AlgorithmDescriptions = map[string]string{ 14 | "SMP": `Stable Marriage Problem 15 | 16 | Find a stable matching between two same-sized sets. 17 | Implements the Gale-Shapley (1962) algorithm. 18 | A stable solution is always guranteed, but it is non-deterministic 19 | and potentially one of many. 20 | 21 | https://en.wikipedia.org/wiki/Stable_marriage_problem.`, 22 | "SRP": `Stable Roommates Problem 23 | 24 | Find a stable matching within an even-sized set. 25 | A stable solution is not guranteed, but is always deterministic if 26 | exists. 27 | Implements Irving's (1985) algorithm. 28 | 29 | https://en.wikipedia.org/wiki/Stable_roommates_problem. 30 | `, 31 | } 32 | 33 | // LsCommand generates the cli.Command definition for the `ls` subcommand. 34 | func LsCommand() *cli.Command { 35 | /* 36 | * The `cli.Command` return value is wrapped in a function so we return a new 37 | * instance of it every time. This avoids caching flags between tests 38 | */ 39 | return &cli.Command{ 40 | Name: "ls", 41 | Usage: "List all matching algorithms", 42 | Action: lsAction, 43 | } 44 | } 45 | 46 | // lsAction is the handler for the `ls` subcommand, which lists all available 47 | // matching algorithms. 48 | func lsAction(ctx *cli.Context) error { 49 | fmt.Println("\nlibmatch supports the following matching algorithms:") 50 | fmt.Println("") 51 | 52 | for algorithm, desc := range AlgorithmDescriptions { 53 | num := 0 54 | lines := strings.Split(desc, "\n") 55 | 56 | for l := range lines { 57 | if num == 0 { 58 | fmt.Printf("\t%v%v%v\t\t%v\n", SEQ_BOLD, algorithm, SEQ_RESET, lines[l]) 59 | } else { 60 | fmt.Printf("\t\t\t%v\n", lines[l]) 61 | } 62 | 63 | num++ 64 | } 65 | 66 | fmt.Println("") 67 | fmt.Println("") 68 | } 69 | return nil 70 | } 71 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= 2 | github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d h1:U+s90UTSYgptZMwQh2aRr3LuazLJIa+Pg3Kc1ylSYVY= 3 | github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU= 4 | github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8= 5 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 6 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 7 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 8 | github.com/russross/blackfriday/v2 v2.0.1 h1:lPqVAte+HuHNfhJ/0LC98ESWRz8afy9tM/0RK8m9o+Q= 9 | github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= 10 | github.com/shurcooL/sanitized_anchor_name v1.0.0 h1:PdmoCO6wvbs+7yrJyMORt4/BmY5IYyJwS/kOiWx8mHo= 11 | github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc= 12 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 13 | github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY= 14 | github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 15 | github.com/urfave/cli/v2 v2.3.0 h1:qph92Y649prgesehzOrQjdWyxFOp/QVM+6imKHad91M= 16 | github.com/urfave/cli/v2 v2.3.0/go.mod h1:LJmUH05zAU44vOAcrfzZQKsZbVcdbOG8rtL3/XcUArI= 17 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= 18 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 19 | gopkg.in/yaml.v2 v2.2.3/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 20 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo= 21 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 22 | -------------------------------------------------------------------------------- /pkg/algo/srp/proposal.go: -------------------------------------------------------------------------------- 1 | package srp 2 | 3 | import ( 4 | "github.com/abhchand/libmatch/pkg/core" 5 | ) 6 | 7 | // phase1Proposal implements the 1st phase of the Irving (1985) algorithm to 8 | // solve the "Stable Roommate Problem". 9 | // 10 | // Each unmatched member "proposes" to their top remaining preference and each 11 | // member that receives a proposal can accept or reject the incoming proposal. 12 | // 13 | // See srp package documentation for more detail 14 | func phase1Proposal(pt *core.PreferenceTable) bool { 15 | for true { 16 | unmatchedMembers := pt.UnmatchedMembers() 17 | 18 | if len(unmatchedMembers) == 0 { 19 | break 20 | } 21 | 22 | if !isStable(pt) { 23 | return false 24 | } 25 | 26 | member := unmatchedMembers[0] 27 | topChoice := member.FirstPreference() 28 | simulateProposal(member, topChoice) 29 | } 30 | 31 | // Check for stability once more since final iteration may have left the 32 | //table unstable 33 | if !isStable(pt) { 34 | return false 35 | } 36 | 37 | return true 38 | } 39 | 40 | // isStable evaluates the preference table and determines whether it is 41 | // "stable". A table is stable when all members' preference lists are non-empty. 42 | func isStable(pt *core.PreferenceTable) bool { 43 | for i := range *pt { 44 | member := (*pt)[i] 45 | 46 | if len(member.PreferenceList().Members()) == 0 { 47 | return false 48 | } 49 | } 50 | 51 | return true 52 | } 53 | 54 | // simulateProposal simulates a proposal between two members 55 | func simulateProposal(proposer, proposed *core.Member) { 56 | if !proposed.HasAcceptedProposal() { 57 | // Proposed member does not have a proposal. Blindly accept this one. 58 | proposed.Accept(proposer) 59 | } else if proposed.WouldPreferProposalFrom(*proposer) { 60 | // Proposed member has a proposal, but the new proposal is better. Reject 61 | // the existing proposal and accept this new one. 62 | proposed.Reject(proposed.CurrentProposer()) 63 | proposed.Accept(proposer) 64 | } else { 65 | // Proposed member has a proposal, but prefers to hold on to it. Reject 66 | // this new proposal. 67 | proposed.Reject(proposer) 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /scripts/build.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | # 4 | # Build and release `libmatch` for a given OS and Architecture 5 | # 6 | 7 | set -e 8 | 9 | function set_os_and_arch() { 10 | if [[ $OS == 'Windows_NT' ]]; then 11 | LIBMATCH_OS=win32 12 | if [[ $PROCESSOR_ARCHITEW6432 == 'AMD64' ]]; then 13 | LIBMATCH_ARCH=amd64 14 | else 15 | if [[ $PROCESSOR_ARCHITECTURE == 'AMD64' ]]; then 16 | LIBMATCH_ARCH=amd64 17 | fi 18 | if [[ $PROCESSOR_ARCHITECTURE == 'x86' ]]; then 19 | LIBMATCH_ARCH=ia32 20 | fi 21 | fi 22 | else 23 | LIBMATCH_OS=$(uname -s) 24 | LIBMATCH_ARCH=$(uname -m) 25 | fi 26 | } 27 | 28 | # Check if binary name was provided 29 | if [[ -z $1 ]]; then 30 | echo "Usage: build.sh BINARY_NAME" 1>&2 31 | exit 1 32 | fi 33 | 34 | CUR_DIR=$( cd "$(dirname "${BASH_SOURCE[0]}")" ; pwd -P ) 35 | 36 | LIBMATCH_BINARY=$1 37 | LIBMATCH_DATE=$(date -u +%Y%m%d) 38 | LIBMATCH_VERSION=$(cat $CUR_DIR/../pkg/version/version.go | grep -m 1 "var version" | sed -e 's/.*\"\(.*\)\"$/\1/g') 39 | 40 | # Determine OS and Architecture 41 | # 42 | # If running on CI (Github Actions), $INPUT_GOOS and $INPUT_GOARCH will be set by 43 | # the `wangyoucao577/go-release-action` Github Action 44 | # 45 | # If they are not set, we attempt to set these values ourselves 46 | if [[ -z "${INPUT_GOOS}" || -z "${INPUT_GOARCH}" ]]; then 47 | set_os_and_arch 48 | else 49 | LIBMATCH_OS=$INPUT_GOOS 50 | LIBMATCH_ARCH=$INPUT_GOARCH 51 | fi 52 | 53 | # Define colors for pretty output 54 | RED='\033[00;31m' 55 | GREEN='\033[00;32m' 56 | RESTORE='\033[0m' 57 | 58 | # Build 59 | echo "Building $LIBMATCH_BINARY..." 60 | echo "Date: $LIBMATCH_DATE" 61 | echo "Version: $LIBMATCH_VERSION" 62 | echo "OS: $LIBMATCH_OS" 63 | echo "Architecture: $LIBMATCH_ARCH" 64 | 65 | go build \ 66 | -ldflags "-s -w -X main.version=${LIBMATCH_DATE}-${LIBMATCH_VERSION}-${LIBMATCH_OS}-${LIBMATCH_ARCH}" \ 67 | -o $LIBMATCH_BINARY cmd/libmatch/main.go 68 | 69 | # Post-build output 70 | if [ $? -eq 0 ]; then 71 | echo "Size: $(du -h $LIBMATCH_BINARY | cut -f 1)" 72 | echo -e "${GREEN}OK${RESTORE}" 73 | else 74 | echo -e "${RED}FAIL${RESTORE}" 75 | exit 1 76 | fi 77 | -------------------------------------------------------------------------------- /pkg/core/preference_list_test.go: -------------------------------------------------------------------------------- 1 | package core 2 | 3 | import ( 4 | "reflect" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/assert" 8 | ) 9 | 10 | func TestNewPreferenceList(t *testing.T) { 11 | m := NewMember("A") 12 | members := []*Member{&m} 13 | pl := NewPreferenceList(members) 14 | 15 | var zero PreferenceList 16 | wanted := reflect.TypeOf(zero).Kind() 17 | 18 | assert.Equal(t, wanted, reflect.TypeOf(pl).Kind()) 19 | assert.Equal(t, members, pl.Members()) 20 | } 21 | 22 | func TestString__PreferenceList(t *testing.T) { 23 | t.Run("success", func(t *testing.T) { 24 | m1 := NewMember("A") 25 | m2 := NewMember("B") 26 | members := []*Member{&m1, &m2} 27 | pl := NewPreferenceList(members) 28 | 29 | assert.Equal(t, "'A', 'B'", pl.String()) 30 | }) 31 | 32 | t.Run("empty list", func(t *testing.T) { 33 | members := []*Member{} 34 | pl := NewPreferenceList(members) 35 | 36 | assert.Equal(t, "", pl.String()) 37 | }) 38 | } 39 | 40 | func TestMembers(t *testing.T) { 41 | memA = Member{name: "A"} 42 | memB = Member{name: "B"} 43 | memC = Member{name: "C"} 44 | 45 | plA = PreferenceList{members: []*Member{&memB, &memC}} 46 | memA.SetPreferenceList(&plA) 47 | 48 | assert.Equal(t, []*Member{&memB, &memC}, plA.Members()) 49 | } 50 | 51 | func TestRemove(t *testing.T) { 52 | t.Run("success", func(t *testing.T) { 53 | memA = Member{name: "A"} 54 | memB = Member{name: "B"} 55 | memC = Member{name: "C"} 56 | memD = Member{name: "D"} 57 | 58 | plA = PreferenceList{members: []*Member{&memB, &memC, &memD}} 59 | memA.SetPreferenceList(&plA) 60 | 61 | plA.Remove(memC) 62 | assert.Equal(t, []*Member{&memB, &memD}, plA.members) 63 | 64 | plA.Remove(memD) 65 | assert.Equal(t, []*Member{&memB}, plA.members) 66 | 67 | plA.Remove(memB) 68 | assert.Equal(t, []*Member{}, plA.members) 69 | }) 70 | 71 | t.Run("handles missing member", func(t *testing.T) { 72 | memA = Member{name: "A"} 73 | memB = Member{name: "B"} 74 | memC = Member{name: "C"} 75 | 76 | plA = PreferenceList{members: []*Member{&memB}} 77 | memA.SetPreferenceList(&plA) 78 | 79 | // Removing a missing element raises no error, just returns 80 | plA.Remove(memC) 81 | assert.Equal(t, []*Member{&memB}, plA.members) 82 | }) 83 | } 84 | -------------------------------------------------------------------------------- /internal/config/config_test.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "flag" 5 | "path/filepath" 6 | "testing" 7 | 8 | "github.com/stretchr/testify/assert" 9 | "github.com/urfave/cli/v2" 10 | ) 11 | 12 | func TestNewConfig(t *testing.T) { 13 | t.Run("success", func(t *testing.T) { 14 | flagSet := flag.NewFlagSet("test", 0) 15 | flagSet.String("algorithm", "SRP", "doc") 16 | flagSet.String("format", "csv", "doc") 17 | flagSet.Bool("debug", false, "doc") 18 | flagSet.Var(cli.NewStringSlice("/tmp/test.json"), "file", "doc") 19 | 20 | app := cli.NewApp() 21 | ctx := cli.NewContext(app, flagSet, nil) 22 | cfg, err := NewConfig(ctx) 23 | 24 | assert.Nil(t, err) 25 | 26 | assert.IsType(t, new(Config), cfg) 27 | assert.Equal(t, "SRP", cfg.Algorithm) 28 | assert.Equal(t, false, cfg.Debug) 29 | assert.Equal(t, "csv", cfg.OutputFormat) 30 | assert.Equal(t, ctx, cfg.CliContext) 31 | assert.Equal(t, []string{"/tmp/test.json"}, cfg.Filenames) 32 | }) 33 | 34 | t.Run("`algorithm` flag is case insensitive", func(t *testing.T) { 35 | flagSet := flag.NewFlagSet("test", 0) 36 | flagSet.String("algorithm", "sRp", "doc") 37 | 38 | app := cli.NewApp() 39 | ctx := cli.NewContext(app, flagSet, nil) 40 | cfg, err := NewConfig(ctx) 41 | 42 | assert.Nil(t, err) 43 | 44 | assert.IsType(t, new(Config), cfg) 45 | assert.Equal(t, "SRP", cfg.Algorithm) 46 | }) 47 | } 48 | 49 | func TestExpandFilenames(t *testing.T) { 50 | t.Run("success", func(t *testing.T) { 51 | flagSet := flag.NewFlagSet("test", 0) 52 | flagSet.Var(cli.NewStringSlice("./test.json"), "file", "doc") 53 | 54 | app := cli.NewApp() 55 | ctx := cli.NewContext(app, flagSet, nil) 56 | cfg, err := NewConfig(ctx) 57 | 58 | curDir, err := filepath.Abs(".") 59 | 60 | assert.Nil(t, err) 61 | 62 | assert.IsType(t, new(Config), cfg) 63 | assert.Equal(t, []string{curDir + "/test.json"}, cfg.Filenames) 64 | }) 65 | 66 | t.Run("handles multiple `file` flags", func(t *testing.T) { 67 | flagSet := flag.NewFlagSet("test", 0) 68 | flagSet.Var(cli.NewStringSlice("./a.json", "./b.json"), "file", "doc") 69 | 70 | app := cli.NewApp() 71 | ctx := cli.NewContext(app, flagSet, nil) 72 | cfg, err := NewConfig(ctx) 73 | 74 | curDir, err := filepath.Abs(".") 75 | 76 | assert.Nil(t, err) 77 | 78 | assert.IsType(t, new(Config), cfg) 79 | assert.Equal(t, []string{curDir + "/a.json", curDir + "/b.json"}, cfg.Filenames) 80 | }) 81 | } 82 | -------------------------------------------------------------------------------- /pkg/algo/srp/cyclical_elimination.go: -------------------------------------------------------------------------------- 1 | package srp 2 | 3 | import ( 4 | "github.com/abhchand/libmatch/pkg/core" 5 | ) 6 | 7 | type cyclePair struct { 8 | x, y *core.Member 9 | } 10 | 11 | // phase3CyclicalElimnation implements the 3rd phase of the Irving (1985) 12 | // algorithm to solve the "Stable Roommate Problem". 13 | // 14 | // In this last phase we attempt to find any preference cycles and reject them. 15 | // 16 | // See srp package documentation for more detail 17 | func phase3CyclicalElimnation(pt *core.PreferenceTable) { 18 | phase3CyclicalElimnationWithSeed(pt, "") 19 | } 20 | 21 | // phase3CyclicalElimnationWithSeed implements the 3rd phase of the Irving 22 | // (1985) algorithm to solve the "Stable Roommate Problem". 23 | // 24 | // It accepts a seed value with which to being processing members 25 | // deterministically. This is useful for testing. 26 | func phase3CyclicalElimnationWithSeed(pt *core.PreferenceTable, seed string) { 27 | var startingMember *core.Member 28 | var loopIdx int 29 | 30 | for !pt.IsComplete() { 31 | if loopIdx == 0 && seed != "" { 32 | startingMember = (*pt)[seed] 33 | } else { 34 | // Find the first memeber with at least two preferences 35 | for _, member := range *pt { 36 | if len(member.PreferenceList().Members()) >= 2 { 37 | startingMember = member 38 | break 39 | } 40 | } 41 | } 42 | 43 | pairs := detectCycle(pt, startingMember) 44 | eliminateCycle(pt, pairs) 45 | 46 | if !pt.IsStable() { 47 | return 48 | } 49 | 50 | loopIdx++ 51 | } 52 | } 53 | 54 | // detectCycle detects preference cycles in a preference table, given a starting 55 | // member. 56 | func detectCycle(pt *core.PreferenceTable, startingMember *core.Member) []cyclePair { 57 | pairs := []cyclePair{ 58 | {x: startingMember}, 59 | } 60 | 61 | lastSeenAt := make(map[string]int, 0) 62 | lastSeenAt[startingMember.Name()] = 1 63 | currentMemberIdx := 0 64 | 65 | for true { 66 | currentMember := pairs[currentMemberIdx].x 67 | 68 | newPair := cyclePair{ 69 | x: currentMember.SecondPreference().LastPreference(), 70 | y: currentMember.SecondPreference(), 71 | } 72 | 73 | pairs = append(pairs, newPair) 74 | 75 | if idx := lastSeenAt[newPair.x.Name()]; idx > 0 { 76 | pairs = pairs[idx:] 77 | break 78 | } 79 | 80 | lastSeenAt[newPair.x.Name()] = currentMemberIdx + 1 81 | currentMemberIdx = currentMemberIdx + 1 82 | } 83 | 84 | return pairs 85 | } 86 | 87 | // eliminateCycle removes an identified preference cycle in a preference table 88 | func eliminateCycle(pt *core.PreferenceTable, pairs []cyclePair) { 89 | for p := range pairs { 90 | (pairs[p].x).Reject(pairs[p].y) 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /pkg/load/load_test.go: -------------------------------------------------------------------------------- 1 | package load 2 | 3 | import ( 4 | "bufio" 5 | "fmt" 6 | "os" 7 | "strings" 8 | "testing" 9 | 10 | "github.com/abhchand/libmatch/pkg/core" 11 | 12 | "github.com/stretchr/testify/assert" 13 | ) 14 | 15 | var testFile = "/tmp/libmatch_test.json" 16 | 17 | func TestLoadFromFile(t *testing.T) { 18 | body := ` 19 | [ 20 | { "name":"A", "preferences": ["B", "C", "D"] }, 21 | { "name":"B", "preferences": ["A", "C", "D"] }, 22 | { "name":"C", "preferences": ["A", "B", "D"] }, 23 | { "name":"D", "preferences": ["A", "B", "C"] } 24 | ] 25 | ` 26 | writeToFile(testFile, body) 27 | 28 | got, err := LoadFromFile(testFile) 29 | 30 | wanted := &[]core.MatchPreference{ 31 | {Name: "A", Preferences: []string{"B", "C", "D"}}, 32 | {Name: "B", Preferences: []string{"A", "C", "D"}}, 33 | {Name: "C", Preferences: []string{"A", "B", "D"}}, 34 | {Name: "D", Preferences: []string{"A", "B", "C"}}, 35 | } 36 | 37 | assert.Nil(t, err) 38 | assert.Equal(t, wanted, got) 39 | } 40 | 41 | func TestLoadFromFile_DoesNotExist(t *testing.T) { 42 | badFile := "/tmp/badfile.json" 43 | 44 | _, err := LoadFromFile(badFile) 45 | 46 | if assert.NotNil(t, err) { 47 | assert.Equal(t, 48 | fmt.Sprintf("open %v: no such file or directory", badFile), err.Error()) 49 | } 50 | } 51 | 52 | func TestLoadFromIO(t *testing.T) { 53 | body := ` 54 | [ 55 | { "name":"A", "preferences": ["B", "C", "D"] }, 56 | { "name":"B", "preferences": ["A", "C", "D"] }, 57 | { "name":"C", "preferences": ["A", "B", "D"] }, 58 | { "name":"D", "preferences": ["A", "B", "C"] } 59 | ] 60 | ` 61 | 62 | got, err := LoadFromIO(strings.NewReader(body)) 63 | 64 | wanted := &[]core.MatchPreference{ 65 | {Name: "A", Preferences: []string{"B", "C", "D"}}, 66 | {Name: "B", Preferences: []string{"A", "C", "D"}}, 67 | {Name: "C", Preferences: []string{"A", "B", "D"}}, 68 | {Name: "D", Preferences: []string{"A", "B", "C"}}, 69 | } 70 | 71 | assert.Nil(t, err) 72 | assert.Equal(t, wanted, got) 73 | } 74 | 75 | func TestLoadFromIO_UnmarshallError(t *testing.T) { 76 | 77 | // Note missing `:` on final row 78 | body := ` 79 | [ 80 | { "name":"A", "preferences": ["B", "C", "D"] }, 81 | { "name":"B", "preferences": ["A", "C", "D"] }, 82 | { "name":"C", "preferences": ["A", "B", "D"] }, 83 | { "name":"D", "preferences" ["A", "B", "C"] } 84 | ] 85 | ` 86 | 87 | _, err := LoadFromIO(strings.NewReader(body)) 88 | 89 | if assert.NotNil(t, err) { 90 | assert.Equal(t, "invalid character '[' after object key", err.Error()) 91 | } 92 | } 93 | 94 | func writeToFile(filename, body string) { 95 | file, err := os.Create(filename) 96 | if err != nil { 97 | fmt.Println(err) 98 | } 99 | 100 | writer := bufio.NewWriter(file) 101 | 102 | _, err = writer.WriteString(body) 103 | if err != nil { 104 | fmt.Printf("Could not create file: %s\n", err.Error()) 105 | } 106 | 107 | writer.Flush() 108 | } 109 | -------------------------------------------------------------------------------- /pkg/load/load.go: -------------------------------------------------------------------------------- 1 | // Package load is responsible for loading preference data from streams and 2 | // files 3 | package load 4 | 5 | import ( 6 | "bufio" 7 | "encoding/json" 8 | "io" 9 | "os" 10 | 11 | "github.com/abhchand/libmatch/pkg/core" 12 | ) 13 | 14 | // LoadFromFile loads preference data from a file containing JSON data. 15 | // 16 | // The structure of the JSON file should be of format: 17 | // 18 | // [ 19 | // { "name":"A", "preferences": ["B", "D", "F", "C", "E"] }, 20 | // { "name":"B", "preferences": ["D", "E", "F", "A", "C"] }, 21 | // { "name":"C", "preferences": ["D", "E", "F", "A", "B"] }, 22 | // { "name":"D", "preferences": ["F", "C", "A", "E", "B"] }, 23 | // { "name":"E", "preferences": ["F", "C", "D", "B", "A"] }, 24 | // { "name":"F", "preferences": ["A", "B", "D", "C", "E"] }, 25 | // ] 26 | // 27 | // The return value is an array of `MatchPreference` structs containing the 28 | // loaded JSON data 29 | // 30 | // *[]core.MatchPreference{ 31 | // {Name: "A", Preferences: []string{"B", "D", "F", "C", "E"}}, 32 | // {Name: "B", Preferences: []string{"D", "E", "F", "A", "C"}}, 33 | // {Name: "C", Preferences: []string{"D", "E", "F", "A", "B"}}, 34 | // {Name: "D", Preferences: []string{"F", "C", "A", "E", "B"}}, 35 | // {Name: "E", Preferences: []string{"F", "C", "D", "B", "A"}}, 36 | // {Name: "F", Preferences: []string{"A", "B", "D", "C", "E"}}, 37 | // } 38 | func LoadFromFile(filename string) (*[]core.MatchPreference, error) { 39 | var data *[]core.MatchPreference 40 | 41 | file, err := os.Open(filename) 42 | if err != nil { 43 | return data, err 44 | } 45 | defer file.Close() 46 | 47 | data, err = LoadFromIO(bufio.NewReader(file)) 48 | return data, err 49 | } 50 | 51 | // LoadFromIO reads match preference data from an `io.Reader`. 52 | // 53 | // The expected data is a JSON formatted preference table of the format: 54 | // 55 | // [ 56 | // { "name":"A", "preferences": ["B", "D", "F", "C", "E"] }, 57 | // { "name":"B", "preferences": ["D", "E", "F", "A", "C"] }, 58 | // { "name":"C", "preferences": ["D", "E", "F", "A", "B"] }, 59 | // { "name":"D", "preferences": ["F", "C", "A", "E", "B"] }, 60 | // { "name":"E", "preferences": ["F", "C", "D", "B", "A"] }, 61 | // { "name":"F", "preferences": ["A", "B", "D", "C", "E"] }, 62 | // ] 63 | // 64 | // The return value is an array of `MatchPreference` structs containing the 65 | // loaded JSON data 66 | // 67 | // *[]libmatch.MatchPreference{ 68 | // {Name: "A", Preferences: []string{"B", "D", "F", "C", "E"}}, 69 | // {Name: "B", Preferences: []string{"D", "E", "F", "A", "C"}}, 70 | // {Name: "C", Preferences: []string{"D", "E", "F", "A", "B"}}, 71 | // {Name: "D", Preferences: []string{"F", "C", "A", "E", "B"}}, 72 | // {Name: "E", Preferences: []string{"F", "C", "D", "B", "A"}}, 73 | // {Name: "F", Preferences: []string{"A", "B", "D", "C", "E"}}, 74 | // } 75 | func LoadFromIO(r io.Reader) (*[]core.MatchPreference, error) { 76 | var data []core.MatchPreference 77 | 78 | rawJson, err := io.ReadAll(r) 79 | if err != nil { 80 | return &data, err 81 | } 82 | 83 | if err := json.Unmarshal(rawJson, &data); err != nil { 84 | return &data, err 85 | } 86 | 87 | return &data, nil 88 | } 89 | -------------------------------------------------------------------------------- /benchmark_test.go: -------------------------------------------------------------------------------- 1 | package libmatch 2 | 3 | /* 4 | Benchmark tests for performance. 5 | 6 | You can run these manually with 7 | 8 | make benchmark 9 | 10 | These also run as part of the CI Build. 11 | */ 12 | 13 | import ( 14 | "testing" 15 | 16 | "github.com/abhchand/libmatch/pkg/core" 17 | ) 18 | 19 | func BenchmarkSolveSMP(b *testing.B) { 20 | prefsA := []core.MatchPreference{ 21 | {Name: "A", Preferences: []string{"R", "L", "M", "S", "Q", "T", "P", "N", "O", "K"}}, 22 | {Name: "B", Preferences: []string{"S", "O", "N", "K", "T", "M", "Q", "P", "R", "L"}}, 23 | {Name: "C", Preferences: []string{"N", "O", "M", "S", "L", "K", "R", "P", "T", "Q"}}, 24 | {Name: "D", Preferences: []string{"S", "Q", "T", "N", "K", "P", "R", "L", "M", "O"}}, 25 | {Name: "E", Preferences: []string{"P", "S", "T", "N", "R", "M", "Q", "K", "O", "L"}}, 26 | {Name: "F", Preferences: []string{"Q", "N", "K", "M", "S", "L", "P", "O", "R", "T"}}, 27 | {Name: "G", Preferences: []string{"T", "R", "K", "Q", "N", "M", "S", "P", "L", "O"}}, 28 | {Name: "H", Preferences: []string{"N", "Q", "P", "L", "M", "O", "S", "K", "T", "R"}}, 29 | {Name: "I", Preferences: []string{"K", "O", "M", "L", "Q", "N", "S", "P", "T", "R"}}, 30 | {Name: "J", Preferences: []string{"L", "N", "Q", "S", "T", "K", "P", "R", "O", "M"}}, 31 | } 32 | 33 | prefsB := []core.MatchPreference{ 34 | {Name: "K", Preferences: []string{"C", "B", "F", "A", "J", "I", "G", "D", "H", "E"}}, 35 | {Name: "L", Preferences: []string{"F", "J", "D", "I", "H", "E", "A", "C", "G", "B"}}, 36 | {Name: "M", Preferences: []string{"D", "J", "H", "C", "F", "G", "B", "I", "E", "A"}}, 37 | {Name: "N", Preferences: []string{"B", "F", "D", "A", "H", "G", "J", "E", "I", "C"}}, 38 | {Name: "O", Preferences: []string{"D", "J", "F", "A", "H", "B", "C", "I", "E", "G"}}, 39 | {Name: "P", Preferences: []string{"A", "D", "C", "B", "J", "G", "I", "H", "F", "E"}}, 40 | {Name: "Q", Preferences: []string{"J", "E", "I", "A", "F", "H", "G", "B", "C", "D"}}, 41 | {Name: "R", Preferences: []string{"B", "H", "J", "A", "C", "I", "G", "F", "D", "E"}}, 42 | {Name: "S", Preferences: []string{"E", "G", "B", "D", "C", "I", "H", "F", "A", "J"}}, 43 | {Name: "T", Preferences: []string{"H", "C", "A", "F", "G", "B", "D", "E", "J", "I"}}, 44 | } 45 | 46 | for i := 0; i < b.N; i++ { 47 | SolveSMP(&prefsA, &prefsB) 48 | } 49 | } 50 | 51 | func BenchmarkSolveSRP(b *testing.B) { 52 | prefs := []core.MatchPreference{ 53 | {Name: "A", Preferences: []string{"H", "J", "E", "B", "D", "I", "C", "G", "F"}}, 54 | {Name: "B", Preferences: []string{"E", "I", "G", "D", "A", "J", "C", "F", "H"}}, 55 | {Name: "C", Preferences: []string{"J", "A", "B", "H", "F", "I", "G", "D", "E"}}, 56 | {Name: "D", Preferences: []string{"I", "C", "E", "G", "B", "A", "J", "F", "H"}}, 57 | {Name: "E", Preferences: []string{"F", "J", "G", "B", "C", "H", "A", "D", "I"}}, 58 | {Name: "F", Preferences: []string{"C", "I", "D", "E", "G", "H", "A", "J", "B"}}, 59 | {Name: "G", Preferences: []string{"E", "H", "F", "A", "J", "C", "D", "B", "I"}}, 60 | {Name: "H", Preferences: []string{"F", "G", "J", "B", "I", "E", "C", "A", "D"}}, 61 | {Name: "I", Preferences: []string{"J", "G", "B", "D", "A", "C", "E", "F", "H"}}, 62 | {Name: "J", Preferences: []string{"C", "F", "A", "B", "I", "G", "H", "D", "E"}}, 63 | } 64 | 65 | for i := 0; i < b.N; i++ { 66 | SolveSRP(&prefs) 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /pkg/algo/smp/doc.go: -------------------------------------------------------------------------------- 1 | /* 2 | Package smp implements the solution to the "Stable Marriage Problem". 3 | 4 | It implements the Gale-Shapley (1962) algorithm, which calculates a stable match 5 | between two groups (alpha and beta) given their individual preferences for 6 | members of the other group. 7 | 8 | ALGORITHM 9 | 10 | See: https://en.wikipedia.org/wiki/Stable_marriage_problem#Solution 11 | 12 | As part of the algorithm, each unmatched member "proposes" to the top 13 | remaining preference on their list. Each recipient of a proposal can take one of 14 | 3 actions - 15 | 16 | 1. The recipient has not received a previous proposal and immediately accepts 17 | 18 | 2. The recipient prefers this new proposal over an existing one. 19 | The recipient "rejects" its initial proposl and accepts this new one 20 | 21 | 3. The recipient prefers the existing proposal over the new one. 22 | The recipient "rejects" the new proposal 23 | 24 | NOTE: Rejections are mutual. If `i` removes `j` from their preference list, 25 | then `j` must also remove `i` from its list 26 | 27 | This cycle continues until every member is matched. 28 | 29 | STABILITY AND DETERMINISM 30 | 31 | Mathematically, every participant is guranteed a match because the algorithm 32 | always converges on a stable solution. 33 | 34 | Notes: 35 | 36 | 1. Multiple stable matchings may exist, and this process will only return one 37 | possible stable matching. 38 | 39 | 2. The algorithm itself prioritizes the preferences of the first specified 40 | preference table over the second. 41 | 42 | 3. While the algorithm is deterministic, the `libmatch` implementation is not. 43 | That is, it is not guranteed to return the same matching everytime. This is due 44 | to the underlying storage mechanism, which uses a `map` type. Go randomizes the 45 | access keys of a `map` and so the algorithm may produce different results on 46 | different runs. 47 | 48 | ALGORITHM EXAMPLE 49 | 50 | See: https://www.youtube.com/watch?v=GsBf3fJFpSw 51 | 52 | Take the following preference tables 53 | 54 | // alpha preferences 55 | A => [O, M, N, L, P] 56 | B => [P, N, M, L, O] 57 | C => [M, P, L, O, N] 58 | D => [P, M, O, N, L] 59 | E => [O, L, M, N, P] 60 | 61 | // beta preferences 62 | L => [D, B, E, C, A] 63 | M => [B, A, D, C, E] 64 | N => [A, C, E, D, B] 65 | O => [D, A, C, B, E] 66 | P => [B, E, A, C, D] 67 | 68 | We can begin the proposal sequence with any unmatched member. As noted above, 69 | different starting members may yield different results. 70 | 71 | In our example we start with 'A'. The sequence of events are - 72 | 73 | 'A' proposes to 'O' 74 | 'O' accepts 'A' 75 | 'B' proposes to 'P' 76 | 'P' accepts 'B' 77 | 'C' proposes to 'M' 78 | 'M' accepts 'C' 79 | 'D' proposes to 'P' 80 | 'P' rejects 'D' 81 | 'E' proposes to 'O' 82 | 'O' rejects 'E' 83 | 'D' proposes to 'M' 84 | 'M' accepts 'D', rejects 'C' 85 | 'E' proposes to 'L' 86 | 'L' accepts 'E' 87 | 'C' proposes to 'P' 88 | 'P' rejects 'C' 89 | 'C' proposes to 'L' 90 | 'L' rejects 'C' 91 | 'C' proposes to 'O' 92 | 'O' rejects 'C' 93 | 'C' proposes to 'N' 94 | 'N' accepts 'C' 95 | 96 | At this point there are no members of the "alpha" group left unmatched (and by 97 | definition, no corresponding members of "beta" left unmatched). All members have 98 | had their proposals accepted by a member of the other group. 99 | 100 | The matching result is: 101 | 102 | A => O 103 | B => P 104 | C => N 105 | D => M 106 | E => L 107 | L => E 108 | M => D 109 | N => C 110 | O => A 111 | P => B 112 | */ 113 | package smp 114 | -------------------------------------------------------------------------------- /pkg/algo/smp/smp_test.go: -------------------------------------------------------------------------------- 1 | package smp 2 | 3 | import ( 4 | "reflect" 5 | "testing" 6 | 7 | "github.com/abhchand/libmatch/pkg/core" 8 | "github.com/stretchr/testify/assert" 9 | ) 10 | 11 | func TestRun(t *testing.T) { 12 | t.Run("success", func(t *testing.T) { 13 | prefsSet := []*[]core.MatchPreference{ 14 | { 15 | {Name: "A", Preferences: []string{"L", "J", "H", "K", "I", "M"}}, 16 | {Name: "B", Preferences: []string{"L", "J", "K", "M", "I", "H"}}, 17 | {Name: "C", Preferences: []string{"L", "J", "M", "I", "K", "H"}}, 18 | {Name: "D", Preferences: []string{"L", "K", "J", "I", "M", "H"}}, 19 | {Name: "E", Preferences: []string{"H", "I", "L", "K", "M", "J"}}, 20 | {Name: "F", Preferences: []string{"J", "K", "L", "M", "H", "I"}}, 21 | }, 22 | { 23 | {Name: "H", Preferences: []string{"F", "E", "C", "A", "D", "B"}}, 24 | {Name: "I", Preferences: []string{"B", "D", "A", "E", "C", "F"}}, 25 | {Name: "J", Preferences: []string{"B", "A", "F", "E", "D", "C"}}, 26 | {Name: "K", Preferences: []string{"A", "E", "C", "F", "D", "B"}}, 27 | {Name: "L", Preferences: []string{"C", "F", "E", "B", "D", "A"}}, 28 | {Name: "M", Preferences: []string{"B", "E", "D", "F", "C", "A"}}, 29 | }, 30 | } 31 | 32 | tables := core.NewPreferenceTablePair(prefsSet[0], prefsSet[1]) 33 | 34 | algoCtx := core.AlgorithmContext{ 35 | TableA: &tables[0], 36 | TableB: &tables[1], 37 | } 38 | 39 | wanted := core.MatchResult{ 40 | Mapping: map[string]string{ 41 | "A": "K", 42 | "B": "J", 43 | "C": "L", 44 | "D": "I", 45 | "E": "H", 46 | "F": "M", 47 | "K": "A", 48 | "J": "B", 49 | "L": "C", 50 | "I": "D", 51 | "M": "F", 52 | "H": "E", 53 | }, 54 | } 55 | 56 | result, err := Run(algoCtx) 57 | 58 | assert.Nil(t, err) 59 | assert.True(t, reflect.DeepEqual(wanted, result)) 60 | }) 61 | 62 | t.Run("table order is reversible", func(t *testing.T) { 63 | prefsSet := []*[]core.MatchPreference{ 64 | { 65 | {Name: "A", Preferences: []string{"L", "J", "H", "K", "I", "M"}}, 66 | {Name: "B", Preferences: []string{"L", "J", "K", "M", "I", "H"}}, 67 | {Name: "C", Preferences: []string{"L", "J", "M", "I", "K", "H"}}, 68 | {Name: "D", Preferences: []string{"L", "K", "J", "I", "M", "H"}}, 69 | {Name: "E", Preferences: []string{"H", "I", "L", "K", "M", "J"}}, 70 | {Name: "F", Preferences: []string{"J", "K", "L", "M", "H", "I"}}, 71 | }, 72 | { 73 | {Name: "H", Preferences: []string{"F", "E", "C", "A", "D", "B"}}, 74 | {Name: "I", Preferences: []string{"B", "D", "A", "E", "C", "F"}}, 75 | {Name: "J", Preferences: []string{"B", "A", "F", "E", "D", "C"}}, 76 | {Name: "K", Preferences: []string{"A", "E", "C", "F", "D", "B"}}, 77 | {Name: "L", Preferences: []string{"C", "F", "E", "B", "D", "A"}}, 78 | {Name: "M", Preferences: []string{"B", "E", "D", "F", "C", "A"}}, 79 | }, 80 | } 81 | 82 | tables := core.NewPreferenceTablePair(prefsSet[0], prefsSet[1]) 83 | 84 | algoCtx := core.AlgorithmContext{ 85 | TableA: &tables[1], 86 | TableB: &tables[0], 87 | } 88 | 89 | wanted := core.MatchResult{ 90 | Mapping: map[string]string{ 91 | "A": "K", 92 | "B": "J", 93 | "C": "L", 94 | "D": "I", 95 | "E": "M", 96 | "F": "H", 97 | "K": "A", 98 | "J": "B", 99 | "L": "C", 100 | "I": "D", 101 | "M": "E", 102 | "H": "F", 103 | }, 104 | } 105 | 106 | result, err := Run(algoCtx) 107 | 108 | assert.Nil(t, err) 109 | assert.True(t, reflect.DeepEqual(wanted, result)) 110 | }) 111 | } 112 | -------------------------------------------------------------------------------- /pkg/validate/single_table.go: -------------------------------------------------------------------------------- 1 | package validate 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | 7 | "github.com/abhchand/libmatch/pkg/core" 8 | ) 9 | 10 | // SingleTableValidator contains all information required to validate a single 11 | // preference table. 12 | type SingleTableValidator struct { 13 | Prefs *[]core.MatchPreference 14 | Table *core.PreferenceTable 15 | Err error 16 | } 17 | 18 | // Validate validates the preference table specified in the struct. 19 | func (v SingleTableValidator) Validate() error { 20 | var err error 21 | 22 | err = v.validateUniqueness() 23 | if err != nil { 24 | return err 25 | } 26 | 27 | err = v.validateSize() 28 | if err != nil { 29 | return err 30 | } 31 | 32 | err = v.validateMembers() 33 | if err != nil { 34 | return err 35 | } 36 | 37 | err = v.validateSymmetry() 38 | if err != nil { 39 | return err 40 | } 41 | 42 | return nil 43 | } 44 | 45 | // validatePrefsUniqueness validates that all member names are unique 46 | func (v SingleTableValidator) validateUniqueness() error { 47 | cache := make(map[string]bool, 0) 48 | 49 | for i := range *v.Prefs { 50 | name := (*v.Prefs)[i].Name 51 | 52 | if cache[name] { 53 | msg := fmt.Sprintf("Member names must be unique. Found duplicate entry '%v'", name) 54 | return errors.New(msg) 55 | } 56 | 57 | cache[name] = true 58 | } 59 | 60 | return nil 61 | } 62 | 63 | // validateSize validates that the table is non-empty and of even size 64 | func (v SingleTableValidator) validateSize() error { 65 | numMembers := len(*v.Table) 66 | 67 | if numMembers == 0 { 68 | return errors.New("Table must be non-empty") 69 | } 70 | 71 | if numMembers%2 != 0 { 72 | return errors.New("Table must have an even number of members") 73 | } 74 | 75 | return nil 76 | } 77 | 78 | // validateMembers validates that members are non-blank. 79 | func (v SingleTableValidator) validateMembers() error { 80 | if (*v.Table)[""] != nil { 81 | return errors.New("All member names must non-blank") 82 | } 83 | 84 | return nil 85 | } 86 | 87 | // validateSymmetry validates whether the table is symmetrical. Each member's 88 | // preferences should contain all other members of the table. 89 | func (v SingleTableValidator) validateSymmetry() error { 90 | 91 | // Build a list of member names 92 | 93 | memberNames := make([]string, len(*v.Table)) 94 | i := 0 95 | for name := range *v.Table { 96 | memberNames[i] = name 97 | i++ 98 | } 99 | 100 | // Verify each member's preference list 101 | 102 | for name := range *v.Table { 103 | // Find index of this member's name 104 | var idx int 105 | for i := range memberNames { 106 | if memberNames[i] == name { 107 | idx = i 108 | break 109 | } 110 | } 111 | 112 | /* 113 | * Remove this member from the member name list 114 | * This result should be the expected preference list (names) for this member 115 | */ 116 | expected := make([]string, len(memberNames)-1) 117 | copy(expected[:idx], memberNames[:idx]) 118 | copy(expected[idx:], memberNames[idx+1:]) 119 | 120 | // Determine the actual list of preference list (names) for this member 121 | prefs := (*v.Table)[name].PreferenceList().Members() 122 | actual := make([]string, len(prefs)) 123 | for i := range prefs { 124 | /* 125 | * The only way a PreferenceList member would be `nil` is if it referenced 126 | * a member that does not exist. That is, no `Member` value could be determined 127 | * when constructing the PreferenceTable. 128 | */ 129 | if prefs[i] == nil { 130 | return errors.New( 131 | fmt.Sprintf("Preference list for '%v' contains at least one unknown member", name)) 132 | } 133 | actual[i] = prefs[i].Name() 134 | } 135 | 136 | // Compare 137 | if !stringSlicesMatch(actual, expected) { 138 | return errors.New( 139 | fmt.Sprintf("Preference list for '%v' does not contain all the required members", name)) 140 | } 141 | } 142 | 143 | return nil 144 | } 145 | -------------------------------------------------------------------------------- /internal/commands/solve.go: -------------------------------------------------------------------------------- 1 | package commands 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | 7 | "github.com/abhchand/libmatch" 8 | "github.com/abhchand/libmatch/internal/config" 9 | "github.com/abhchand/libmatch/pkg/core" 10 | "github.com/abhchand/libmatch/pkg/load" 11 | "github.com/urfave/cli/v2" 12 | ) 13 | 14 | // Static configuration of the matching algorithms 15 | var MATCHING_ALGORITHMS_CFG = map[string]struct { 16 | numInputFilesRequired int 17 | }{ 18 | "SMP": { 19 | numInputFilesRequired: 2, 20 | }, 21 | "SRP": { 22 | numInputFilesRequired: 1, 23 | }, 24 | } 25 | var OUTPUT_FORMATS = [2]string{"csv", "json"} 26 | 27 | // SolveCommand generates the cli.Command definition for the `solve` subcommand. 28 | func SolveCommand() *cli.Command { 29 | /* 30 | * The `cli.Command` return value is wrapped in a function so we return a new 31 | * instance of it every time. This avoids caching flags between tests 32 | */ 33 | return &cli.Command{ 34 | Name: "solve", 35 | Usage: "Run a matching algorithm", 36 | Action: solveAction, 37 | Flags: []cli.Flag{ 38 | &cli.StringFlag{ 39 | Name: "algorithm", 40 | Usage: "Algorithm used to determine matches. See all algorithms with \"libmatch ls\"", 41 | Required: true, 42 | Aliases: []string{"a"}, 43 | }, 44 | &cli.StringSliceFlag{ 45 | Name: "file", 46 | Usage: "JSON-formatted file containing list of matching preferences", 47 | Required: true, 48 | Aliases: []string{"f"}, 49 | }, 50 | &cli.StringFlag{ 51 | Name: "format", 52 | Usage: "Output format to print results. Must be one of 'csv', 'json'", 53 | Required: false, 54 | Value: "csv", 55 | Aliases: []string{"o"}, 56 | }, 57 | }, 58 | } 59 | } 60 | 61 | // solveAction is the handler for the `solve` subcommand, which runs a the 62 | // specified matching algorithm 63 | func solveAction(ctx *cli.Context) error { 64 | var result core.MatchResult 65 | 66 | // Create a new libmatch `Config` 67 | cfg, err := config.NewConfig(ctx) 68 | if err != nil { 69 | return err 70 | } 71 | 72 | // Validate the config, which validates the CLI input flags 73 | if err = validateConfig(*cfg); err != nil { 74 | return err 75 | } 76 | 77 | // Read one or more input files and load data into `core.MatchPreference` structures 78 | prefsSet, err := loadFiles(*cfg) 79 | if err != nil { 80 | return err 81 | } 82 | 83 | /* 84 | * Call the appropriate `libmatch` API method for the specified 85 | * Matching Algorithm 86 | */ 87 | switch cfg.Algorithm { 88 | case "SMP": 89 | result, err = libmatch.SolveSMP(prefsSet[0], prefsSet[1]) 90 | case "SRP": 91 | result, err = libmatch.SolveSRP(prefsSet[0]) 92 | } 93 | 94 | if err != nil { 95 | return err 96 | } 97 | 98 | // Print the results in the desired output format 99 | result.Print(cfg.OutputFormat) 100 | 101 | return nil 102 | } 103 | 104 | // validateConfig validates the configuration containing the CLI input flags 105 | func validateConfig(cfg config.Config) error { 106 | mac := MATCHING_ALGORITHMS_CFG[cfg.Algorithm] 107 | 108 | // Verify `--algorithm` value is valid 109 | valid := false 110 | if mac.numInputFilesRequired == 0 { 111 | return errors.New(fmt.Sprintf("Unknown `--algorithm` value: %v", cfg.Algorithm)) 112 | } 113 | 114 | // Verify number of `--file` inputs 115 | if len(cfg.Filenames) != mac.numInputFilesRequired { 116 | return errors.New( 117 | fmt.Sprintf("Expected --file to be specified exactly %v time(s)", mac.numInputFilesRequired)) 118 | } 119 | 120 | // Verify `--format` value is valid 121 | valid = false 122 | for i := range OUTPUT_FORMATS { 123 | if cfg.OutputFormat == OUTPUT_FORMATS[i] { 124 | valid = true 125 | break 126 | } 127 | } 128 | 129 | if !(valid) { 130 | return errors.New(fmt.Sprintf("Unknown `--format` value: %v", cfg.OutputFormat)) 131 | } 132 | 133 | return nil 134 | } 135 | 136 | // loadFiles reads one or more files specified with the `--flag` configuration 137 | // input and loads the data into `core.MatchPreference` structures 138 | func loadFiles(cfg config.Config) ([]*[]core.MatchPreference, error) { 139 | prefsSet := make([]*[]core.MatchPreference, len(cfg.Filenames)) 140 | 141 | for i := range cfg.Filenames { 142 | prefs, err := load.LoadFromFile(cfg.Filenames[i]) 143 | 144 | if err != nil { 145 | return prefsSet, err 146 | } 147 | 148 | prefsSet[i] = prefs 149 | } 150 | 151 | return prefsSet, nil 152 | } 153 | -------------------------------------------------------------------------------- /pkg/validate/double_table.go: -------------------------------------------------------------------------------- 1 | package validate 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | 7 | "github.com/abhchand/libmatch/pkg/core" 8 | ) 9 | 10 | // DoubleTableValidator contains all information required to validate a pair 11 | // of preference tables. 12 | type DoubleTableValidator struct { 13 | PrefsSet []*[]core.MatchPreference 14 | Tables []*core.PreferenceTable 15 | Err error 16 | } 17 | 18 | // Validate validates the pair of preference tables specified in the struct. 19 | func (v DoubleTableValidator) Validate() error { 20 | var err error 21 | 22 | // This should already be verified upstream 23 | if len(v.PrefsSet) != 2 || len(v.Tables) != 2 { 24 | return errors.New("Internal error: expected exactly 2 Prefs and 2 Tables") 25 | } 26 | 27 | err = v.validatePrefsUniqueness() 28 | if err != nil { 29 | return err 30 | } 31 | 32 | err = v.validateTableUniqueness() 33 | if err != nil { 34 | return err 35 | } 36 | 37 | err = v.validateSize() 38 | if err != nil { 39 | return err 40 | } 41 | 42 | err = v.validateMembers() 43 | if err != nil { 44 | return err 45 | } 46 | 47 | err = v.validateSymmetry() 48 | if err != nil { 49 | return err 50 | } 51 | 52 | return nil 53 | } 54 | 55 | // validatePrefsUniqueness validates that all member names are unique 56 | func (v DoubleTableValidator) validatePrefsUniqueness() error { 57 | caches := make([]map[string]bool, 2) 58 | 59 | for e := range v.PrefsSet { 60 | caches[e] = make(map[string]bool, 0) 61 | 62 | for i := range *v.PrefsSet[e] { 63 | name := (*v.PrefsSet[e])[i].Name 64 | 65 | if caches[e][name] { 66 | msg := fmt.Sprintf("Member names must be unique. Found duplicate entry '%v'", name) 67 | return errors.New(msg) 68 | } 69 | 70 | caches[e][name] = true 71 | } 72 | } 73 | 74 | return nil 75 | } 76 | 77 | // validateTableUniqueness validates that both tables have distinct sets of 78 | // members 79 | func (v DoubleTableValidator) validateTableUniqueness() error { 80 | for t := range v.Tables { 81 | table := v.Tables[t] 82 | otherTable := v.Tables[1-t] 83 | 84 | for name := range *table { 85 | if (*otherTable)[name] != nil { 86 | msg := fmt.Sprintf("Tables must have distinct members. '%v' found in both tables", name) 87 | return errors.New(msg) 88 | } 89 | } 90 | } 91 | 92 | return nil 93 | } 94 | 95 | // validateSize validates that both tables are non-empty and of equal size 96 | func (v DoubleTableValidator) validateSize() error { 97 | sizes := make([]int, 2) 98 | 99 | for t := range v.Tables { 100 | sizes[t] = len(*v.Tables[t]) 101 | 102 | if sizes[t] == 0 { 103 | return errors.New("Table must be non-empty") 104 | } 105 | } 106 | 107 | if sizes[0] != sizes[1] { 108 | return errors.New("Tables must be the same size") 109 | } 110 | 111 | return nil 112 | } 113 | 114 | // validateMembers validates that members are non-blank. 115 | func (v DoubleTableValidator) validateMembers() error { 116 | for t := range v.Tables { 117 | if (*v.Tables[t])[""] != nil { 118 | return errors.New("All member names must non-blank") 119 | } 120 | } 121 | 122 | return nil 123 | } 124 | 125 | // validateSymmetry validates whether the tables are symmetrical. Each member's 126 | // preferences should contain all members of the other table. 127 | func (v DoubleTableValidator) validateSymmetry() error { 128 | 129 | // Build a list of member names of both tables 130 | 131 | memberNames := make([][]string, 2) 132 | 133 | for t := range v.Tables { 134 | memberNames[t] = make([]string, len(*v.Tables[t])) 135 | 136 | i := 0 137 | for name := range *v.Tables[t] { 138 | memberNames[t][i] = name 139 | i++ 140 | } 141 | } 142 | 143 | // Verify each member's preference list across both tables 144 | 145 | for t := range v.Tables { 146 | table := v.Tables[t] 147 | 148 | for name, member := range *table { 149 | prefs := member.PreferenceList().Members() 150 | 151 | actual := make([]string, len(prefs)) 152 | for p := range prefs { 153 | /* 154 | * The only way a PreferenceList member would be `nil` is if it referenced 155 | * a member that does not exist. That is, no `Member` value could be determined 156 | * when constructing the PreferenceTable. 157 | */ 158 | if prefs[p] == nil { 159 | return errors.New( 160 | fmt.Sprintf("Preference list for '%v' contains at least one unknown member", name)) 161 | } 162 | actual[p] = prefs[p].Name() 163 | } 164 | 165 | expected := memberNames[1-t] 166 | 167 | if !stringSlicesMatch(actual, expected) { 168 | return errors.New( 169 | fmt.Sprintf("Preference list for '%v' does not contain all the required members", name)) 170 | } 171 | } 172 | } 173 | 174 | return nil 175 | } 176 | -------------------------------------------------------------------------------- /pkg/core/preference_table.go: -------------------------------------------------------------------------------- 1 | package core 2 | 3 | import ( 4 | "fmt" 5 | "regexp" 6 | "sort" 7 | ) 8 | 9 | // PreferenceTable models a preference table for a set of members. In theory 10 | // it maps a member name to its preferene list. In reality, the Member data 11 | // model already contains a member's name and preference list. So this data 12 | // model is a glorified lookup table that maps a member's string name to its 13 | // Member struct object. 14 | // 15 | // This could have been modeled without a dedicated struct, but using a struct 16 | // also provides the convenience of defining additional methods that apply to 17 | // the table itself. 18 | type PreferenceTable map[string]*Member 19 | 20 | // NewPreferenceTable creates a new preference table given a list of match 21 | // preferences (likely loaded from JSON data). Each member will have a 22 | // preference list of all other members in the same set. 23 | func NewPreferenceTable(prefs *[]MatchPreference) PreferenceTable { 24 | p := *prefs 25 | 26 | table := make(PreferenceTable, len(*prefs)) 27 | 28 | // First pass: build a list of members as a lookup table 29 | for i := range p { 30 | m := NewMember(p[i].Name) 31 | table[p[i].Name] = &m 32 | } 33 | 34 | // Second pass, build preference list for each member 35 | // that contains references to other members 36 | for i := range p { 37 | name := p[i].Name 38 | m := table[name] 39 | plMembers := make([]*Member, len(p[i].Preferences)) 40 | 41 | for j := range p[i].Preferences { 42 | prefName := p[i].Preferences[j] 43 | pref := table[prefName] 44 | plMembers[j] = pref 45 | } 46 | 47 | m.preferenceList = &PreferenceList{members: plMembers} 48 | table[name] = m 49 | } 50 | 51 | return table 52 | } 53 | 54 | // NewPreferenceTablePair creates a pair of preference tables where each 55 | // member has a preference list of members in the *other* set. 56 | func NewPreferenceTablePair(prefsA, prefsB *[]MatchPreference) []PreferenceTable { 57 | prefsSet := []*[]MatchPreference{prefsA, prefsB} 58 | 59 | tables := make([]PreferenceTable, 2) 60 | tables[0] = make(PreferenceTable, len(*prefsA)) 61 | tables[1] = make(PreferenceTable, len(*prefsB)) 62 | 63 | // First pass: build a list of members as a lookup table 64 | for i := range prefsSet { 65 | prefs := prefsSet[i] 66 | 67 | for j := range *prefs { 68 | name := (*prefs)[j].Name 69 | m := NewMember(name) 70 | tables[i][name] = &m 71 | } 72 | } 73 | 74 | // Second pass, build preference list for each member 75 | // that contains references to the other table's members 76 | for i := range prefsSet { 77 | p := *prefsSet[i] 78 | 79 | table := tables[i] 80 | otherTable := tables[1-i] 81 | 82 | for j := range p { 83 | name := p[j].Name 84 | m := table[name] 85 | plMembers := make([]*Member, len(p[j].Preferences)) 86 | 87 | for k := range p[j].Preferences { 88 | prefName := p[j].Preferences[k] 89 | pref := otherTable[prefName] 90 | plMembers[k] = pref 91 | } 92 | 93 | m.preferenceList = &PreferenceList{members: plMembers} 94 | tables[i][name] = m 95 | } 96 | } 97 | 98 | return tables 99 | } 100 | 101 | // String returns a human readable representation of this preference table 102 | func (pt PreferenceTable) String() string { 103 | var str string 104 | 105 | // Sort map keys so we can iterate over the map below deterministically 106 | keys := make([]string, 0, len(pt)) 107 | for k := range pt { 108 | keys = append(keys, k) 109 | } 110 | sort.Strings(keys) 111 | 112 | for k := range keys { 113 | member := pt[keys[k]] 114 | preferenceList := member.PreferenceList().String() 115 | 116 | if member.CurrentProposer() != nil { 117 | currentProposer := member.CurrentProposer().String() 118 | 119 | pattern := regexp.MustCompile(currentProposer) 120 | newPattern := fmt.Sprintf("%v+", currentProposer) 121 | 122 | preferenceList = pattern.ReplaceAllString(preferenceList, newPattern) 123 | } 124 | 125 | str = str + fmt.Sprintf("%v\t=>\t%v\n", member, preferenceList) 126 | } 127 | 128 | return str 129 | } 130 | 131 | // UnmatchedMembers returns a list of all members in this table who are still 132 | // unmatched. 133 | func (pt PreferenceTable) UnmatchedMembers() []*Member { 134 | var unmatched []*Member 135 | 136 | for m := range pt { 137 | if pt[m].CurrentAcceptor() == nil { 138 | unmatched = append(unmatched, pt[m]) 139 | } 140 | } 141 | 142 | return unmatched 143 | } 144 | 145 | // IsStable indicates whether this table is considered mathematically stable. 146 | // That is, no member should have an empty preference list. 147 | func (pt PreferenceTable) IsStable() bool { 148 | for m := range pt { 149 | if len(pt[m].PreferenceList().members) == 0 { 150 | return false 151 | } 152 | } 153 | 154 | return true 155 | } 156 | 157 | // IsComplete indicates whether this table is considered complete. That is, 158 | // every member should have exactly 1 member remaining in its preference list. 159 | func (pt PreferenceTable) IsComplete() bool { 160 | for m := range pt { 161 | if len(pt[m].PreferenceList().members) != 1 { 162 | return false 163 | } 164 | } 165 | 166 | return true 167 | } 168 | -------------------------------------------------------------------------------- /pkg/validate/single_table_test.go: -------------------------------------------------------------------------------- 1 | package validate 2 | 3 | import ( 4 | "fmt" 5 | "testing" 6 | 7 | "github.com/abhchand/libmatch/pkg/core" 8 | "github.com/stretchr/testify/assert" 9 | ) 10 | 11 | func TestValidate__SingleTable(t *testing.T) { 12 | t.Run("success", func(t *testing.T) { 13 | prefs := []core.MatchPreference{ 14 | {Name: "A", Preferences: []string{"B", "C", "D"}}, 15 | {Name: "B", Preferences: []string{"A", "C", "D"}}, 16 | {Name: "C", Preferences: []string{"A", "B", "D"}}, 17 | {Name: "D", Preferences: []string{"A", "B", "C"}}, 18 | } 19 | 20 | table := core.NewPreferenceTable(&prefs) 21 | 22 | v := SingleTableValidator{Prefs: &prefs, Table: &table} 23 | err := v.Validate() 24 | 25 | assert.Nil(t, err) 26 | }) 27 | 28 | t.Run("duplicate member name", func(t *testing.T) { 29 | prefs := []core.MatchPreference{ 30 | {Name: "A", Preferences: []string{"B", "C", "D"}}, 31 | {Name: "B", Preferences: []string{"A", "C", "D"}}, 32 | {Name: "C", Preferences: []string{"A", "B", "D"}}, 33 | {Name: "A", Preferences: []string{"A", "B", "C"}}, 34 | } 35 | 36 | table := core.NewPreferenceTable(&prefs) 37 | 38 | v := SingleTableValidator{Prefs: &prefs, Table: &table} 39 | err := v.Validate() 40 | 41 | if assert.NotNil(t, err) { 42 | msg := fmt.Sprintf("Member names must be unique. Found duplicate entry '%v'", "A") 43 | assert.Equal(t, msg, err.Error()) 44 | } 45 | }) 46 | 47 | t.Run("empty table", func(t *testing.T) { 48 | prefs := []core.MatchPreference{} 49 | 50 | table := core.NewPreferenceTable(&prefs) 51 | 52 | v := SingleTableValidator{Prefs: &prefs, Table: &table} 53 | err := v.Validate() 54 | 55 | if assert.NotNil(t, err) { 56 | assert.Equal(t, "Table must be non-empty", err.Error()) 57 | } 58 | }) 59 | 60 | t.Run("odd number of members", func(t *testing.T) { 61 | prefs := []core.MatchPreference{ 62 | {Name: "A", Preferences: []string{"B", "C", "D"}}, 63 | {Name: "B", Preferences: []string{"A", "C", "D"}}, 64 | {Name: "C", Preferences: []string{"A", "B", "D"}}, 65 | } 66 | 67 | table := core.NewPreferenceTable(&prefs) 68 | 69 | v := SingleTableValidator{Prefs: &prefs, Table: &table} 70 | err := v.Validate() 71 | 72 | if assert.NotNil(t, err) { 73 | assert.Equal(t, "Table must have an even number of members", err.Error()) 74 | } 75 | }) 76 | 77 | t.Run("empty member", func(t *testing.T) { 78 | prefs := []core.MatchPreference{ 79 | {Name: "A", Preferences: []string{"B", "C", "D"}}, 80 | {Name: "B", Preferences: []string{"A", "C", "D"}}, 81 | {Name: "C", Preferences: []string{"A", "B", "D"}}, 82 | {Name: "", Preferences: []string{"A", "B", "C"}}, 83 | } 84 | 85 | table := core.NewPreferenceTable(&prefs) 86 | 87 | v := SingleTableValidator{Prefs: &prefs, Table: &table} 88 | err := v.Validate() 89 | 90 | if assert.NotNil(t, err) { 91 | assert.Equal(t, "All member names must non-blank", err.Error()) 92 | } 93 | }) 94 | 95 | t.Run("member names are case sensitive", func(t *testing.T) { 96 | prefs := []core.MatchPreference{ 97 | {Name: "A", Preferences: []string{"B", "C", "a"}}, 98 | {Name: "B", Preferences: []string{"A", "C", "a"}}, 99 | {Name: "C", Preferences: []string{"A", "B", "a"}}, 100 | {Name: "a", Preferences: []string{"A", "B", "C"}}, 101 | } 102 | 103 | table := core.NewPreferenceTable(&prefs) 104 | 105 | v := SingleTableValidator{Prefs: &prefs, Table: &table} 106 | err := v.Validate() 107 | 108 | assert.Nil(t, err) 109 | }) 110 | 111 | t.Run("asymmetrical empty list", func(t *testing.T) { 112 | prefs := []core.MatchPreference{ 113 | {Name: "A", Preferences: []string{"B", "C", "D"}}, 114 | {Name: "B", Preferences: []string{}}, 115 | {Name: "C", Preferences: []string{"A", "B", "D"}}, 116 | {Name: "D", Preferences: []string{"A", "B", "C"}}, 117 | } 118 | 119 | table := core.NewPreferenceTable(&prefs) 120 | 121 | v := SingleTableValidator{Prefs: &prefs, Table: &table} 122 | err := v.Validate() 123 | 124 | if assert.NotNil(t, err) { 125 | wanted := fmt.Sprintf("Preference list for '%v' does not contain all the required members", "B") 126 | assert.Equal(t, wanted, err.Error()) 127 | } 128 | }) 129 | 130 | t.Run("asymmetrical mismatched list", func(t *testing.T) { 131 | prefs := []core.MatchPreference{ 132 | {Name: "A", Preferences: []string{"B", "C", "D"}}, 133 | {Name: "B", Preferences: []string{"A", "C"}}, 134 | {Name: "C", Preferences: []string{"A", "B", "D"}}, 135 | {Name: "D", Preferences: []string{"A", "B", "C"}}, 136 | } 137 | 138 | table := core.NewPreferenceTable(&prefs) 139 | 140 | v := SingleTableValidator{Prefs: &prefs, Table: &table} 141 | err := v.Validate() 142 | 143 | if assert.NotNil(t, err) { 144 | wanted := fmt.Sprintf("Preference list for '%v' does not contain all the required members", "B") 145 | assert.Equal(t, wanted, err.Error()) 146 | } 147 | }) 148 | 149 | t.Run("asymmetrical unknown member", func(t *testing.T) { 150 | prefs := []core.MatchPreference{ 151 | {Name: "A", Preferences: []string{"B", "C", "D"}}, 152 | {Name: "B", Preferences: []string{"A", "C", "X"}}, 153 | {Name: "C", Preferences: []string{"A", "B", "D"}}, 154 | {Name: "D", Preferences: []string{"A", "B", "C"}}, 155 | } 156 | 157 | table := core.NewPreferenceTable(&prefs) 158 | 159 | v := SingleTableValidator{Prefs: &prefs, Table: &table} 160 | err := v.Validate() 161 | 162 | if assert.NotNil(t, err) { 163 | wanted := fmt.Sprintf("Preference list for '%v' contains at least one unknown member", "B") 164 | assert.Equal(t, wanted, err.Error()) 165 | } 166 | }) 167 | } 168 | -------------------------------------------------------------------------------- /pkg/algo/srp/proposal_test.go: -------------------------------------------------------------------------------- 1 | package srp 2 | 3 | import ( 4 | "reflect" 5 | "testing" 6 | 7 | "github.com/abhchand/libmatch/pkg/core" 8 | "github.com/stretchr/testify/assert" 9 | ) 10 | 11 | func TestPhase1Proposal(t *testing.T) { 12 | t.Run("success", func(t *testing.T) { 13 | pt := core.NewPreferenceTable(&[]core.MatchPreference{ 14 | {Name: "A", Preferences: []string{"B", "D", "F", "C", "E"}}, 15 | {Name: "B", Preferences: []string{"D", "E", "F", "A", "C"}}, 16 | {Name: "C", Preferences: []string{"D", "E", "F", "A", "B"}}, 17 | {Name: "D", Preferences: []string{"F", "C", "A", "E", "B"}}, 18 | {Name: "E", Preferences: []string{"F", "C", "D", "B", "A"}}, 19 | {Name: "F", Preferences: []string{"A", "B", "D", "C", "E"}}, 20 | }) 21 | 22 | wanted := core.NewPreferenceTable(&[]core.MatchPreference{ 23 | {Name: "A", Preferences: []string{"B", "D", "F", "C", "E"}}, 24 | {Name: "B", Preferences: []string{"E", "F", "A", "C"}}, 25 | {Name: "C", Preferences: []string{"D", "E", "F", "A", "B"}}, 26 | {Name: "D", Preferences: []string{"F", "C", "A", "E"}}, 27 | {Name: "E", Preferences: []string{"C", "D", "B", "A"}}, 28 | {Name: "F", Preferences: []string{"A", "B", "D", "C"}}, 29 | }) 30 | 31 | wanted["A"].Accept(wanted["F"]) 32 | wanted["B"].Accept(wanted["A"]) 33 | wanted["C"].Accept(wanted["E"]) 34 | wanted["D"].Accept(wanted["C"]) 35 | wanted["E"].Accept(wanted["B"]) 36 | wanted["F"].Accept(wanted["D"]) 37 | 38 | isStable := phase1Proposal(&pt) 39 | 40 | assert.True(t, isStable) 41 | assert.True(t, reflect.DeepEqual(wanted, pt)) 42 | }) 43 | 44 | t.Run("no stable solution exists", func(t *testing.T) { 45 | /* 46 | * All other rooommates prefer "D" the least and prefer each other 47 | * with equal priority. In this case D's preference list will get 48 | * exhausted as no one prefers D to any other match 49 | */ 50 | pt := core.NewPreferenceTable(&[]core.MatchPreference{ 51 | {Name: "A", Preferences: []string{"B", "C", "D"}}, 52 | {Name: "B", Preferences: []string{"C", "A", "D"}}, 53 | {Name: "C", Preferences: []string{"A", "B", "D"}}, 54 | {Name: "D", Preferences: []string{"A", "B", "C"}}, 55 | }) 56 | 57 | isStable := phase1Proposal(&pt) 58 | 59 | assert.False(t, isStable) 60 | }) 61 | } 62 | 63 | func TestIsStable(t *testing.T) { 64 | pt := core.NewPreferenceTable(&[]core.MatchPreference{ 65 | {Name: "A", Preferences: []string{"B", "C", "D"}}, 66 | {Name: "B", Preferences: []string{"C", "A", "D"}}, 67 | {Name: "C", Preferences: []string{"A", "B", "D"}}, 68 | {Name: "D", Preferences: []string{"A", "B", "C"}}, 69 | }) 70 | 71 | pt["C"].Reject(pt["A"]) 72 | pt["C"].Reject(pt["B"]) 73 | assert.True(t, isStable(&pt)) 74 | 75 | // Rejecting the last available preference makes the table unstable 76 | pt["C"].Reject(pt["D"]) 77 | assert.False(t, isStable(&pt)) 78 | } 79 | 80 | func TestSimulateProposal(t *testing.T) { 81 | t.Run("proposed has no accepted proposal", func(t *testing.T) { 82 | pt := core.NewPreferenceTable(&[]core.MatchPreference{ 83 | {Name: "A", Preferences: []string{"B", "C", "D"}}, 84 | {Name: "B", Preferences: []string{"C", "A", "D"}}, 85 | {Name: "C", Preferences: []string{"A", "B", "D"}}, 86 | {Name: "D", Preferences: []string{"A", "B", "C"}}, 87 | }) 88 | 89 | // C proposes to A, who has no other accepted proposal and will accept 90 | simulateProposal(pt["C"], pt["A"]) 91 | 92 | wanted := core.NewPreferenceTable(&[]core.MatchPreference{ 93 | {Name: "A", Preferences: []string{"B", "C", "D"}}, 94 | {Name: "B", Preferences: []string{"C", "A", "D"}}, 95 | {Name: "C", Preferences: []string{"A", "B", "D"}}, 96 | {Name: "D", Preferences: []string{"A", "B", "C"}}, 97 | }) 98 | 99 | wanted["A"].Accept(wanted["C"]) 100 | 101 | assert.True(t, reflect.DeepEqual(wanted, pt)) 102 | }) 103 | 104 | t.Run("proposed prefers new proposal to existing one", func(t *testing.T) { 105 | pt := core.NewPreferenceTable(&[]core.MatchPreference{ 106 | {Name: "A", Preferences: []string{"B", "C", "D"}}, 107 | {Name: "B", Preferences: []string{"C", "A", "D"}}, 108 | {Name: "C", Preferences: []string{"A", "B", "D"}}, 109 | {Name: "D", Preferences: []string{"A", "B", "C"}}, 110 | }) 111 | 112 | // C proposes to A, then B proposes to A 113 | // A will prefer the newer proosal (B) and mutually reject the former proposal (C) 114 | simulateProposal(pt["C"], pt["A"]) 115 | simulateProposal(pt["B"], pt["A"]) 116 | 117 | wanted := core.NewPreferenceTable(&[]core.MatchPreference{ 118 | {Name: "A", Preferences: []string{"B", "D"}}, 119 | {Name: "B", Preferences: []string{"C", "A", "D"}}, 120 | {Name: "C", Preferences: []string{"B", "D"}}, 121 | {Name: "D", Preferences: []string{"A", "B", "C"}}, 122 | }) 123 | 124 | wanted["A"].Accept(wanted["B"]) 125 | 126 | assert.True(t, reflect.DeepEqual(wanted, pt)) 127 | }) 128 | 129 | t.Run("proposed doesn't prefer new proposal to existing one", func(t *testing.T) { 130 | pt := core.NewPreferenceTable(&[]core.MatchPreference{ 131 | {Name: "A", Preferences: []string{"B", "C", "D"}}, 132 | {Name: "B", Preferences: []string{"C", "A", "D"}}, 133 | {Name: "C", Preferences: []string{"A", "B", "D"}}, 134 | {Name: "D", Preferences: []string{"A", "B", "C"}}, 135 | }) 136 | 137 | // C proposes to A, then D proposes to A 138 | // A will prefer the former proosal (C) and mutually reject the newer proposal (D) 139 | simulateProposal(pt["C"], pt["A"]) 140 | simulateProposal(pt["D"], pt["A"]) 141 | 142 | wanted := core.NewPreferenceTable(&[]core.MatchPreference{ 143 | {Name: "A", Preferences: []string{"B", "C"}}, 144 | {Name: "B", Preferences: []string{"C", "A", "D"}}, 145 | {Name: "C", Preferences: []string{"A", "B", "D"}}, 146 | {Name: "D", Preferences: []string{"B", "C"}}, 147 | }) 148 | 149 | wanted["A"].Accept(wanted["C"]) 150 | 151 | assert.True(t, reflect.DeepEqual(wanted, pt)) 152 | }) 153 | } 154 | -------------------------------------------------------------------------------- /pkg/core/member.go: -------------------------------------------------------------------------------- 1 | package core 2 | 3 | // Member models an individual element that is to be matched with another 4 | // member. Each Member will have an ordered preference list of other members. 5 | // 6 | // As part of the Algorithm execution, each Member can hold a conditional 7 | // "proposal" from another Member. Over the course of the algorithm run, the 8 | // Member may keep this proposal or reject this proposal for a more preferred 9 | // proposal. 10 | type Member struct { 11 | name string 12 | preferenceList *PreferenceList 13 | acceptedProposalFrom *Member 14 | } 15 | 16 | // NewMember builds a new member from a unique name 17 | func NewMember(name string) Member { 18 | return Member{name: name} 19 | } 20 | 21 | // String returns a human readable representation of this member 22 | func (m Member) String() string { 23 | return "'" + m.name + "'" 24 | } 25 | 26 | // Name returns the name of this member 27 | func (m Member) Name() string { 28 | return m.name 29 | } 30 | 31 | // PreferenceList returns the current list of other members in order of 32 | // preference. It may change over time as the algorithm runs and eliminates 33 | // certain elements of the list. 34 | func (m Member) PreferenceList() *PreferenceList { 35 | return m.preferenceList 36 | } 37 | 38 | // SetPreferenceList sets the preference list for this member 39 | func (m *Member) SetPreferenceList(pl *PreferenceList) { 40 | m.preferenceList = pl 41 | } 42 | 43 | // CurrentProposer returns the member who currently holds an accepted proposal 44 | // from this Member. 45 | func (m Member) CurrentProposer() *Member { 46 | return m.acceptedProposalFrom 47 | } 48 | 49 | // CurrentAcceptor returns the Member who has currently accepted a proposal from 50 | // this Member. 51 | func (m Member) CurrentAcceptor() *Member { 52 | for i := range m.preferenceList.members { 53 | them := m.preferenceList.members[i] 54 | theirProposer := them.CurrentProposer() 55 | 56 | if theirProposer != nil && theirProposer.Name() == m.name { 57 | return m.preferenceList.members[i] 58 | } 59 | } 60 | 61 | return nil 62 | } 63 | 64 | // HasAcceptedProposal indicates whether any member currently holds an accepted 65 | // proposal from this member. 66 | func (m Member) HasAcceptedProposal() bool { 67 | return m.acceptedProposalFrom != nil 68 | } 69 | 70 | // Accept accepts an incoming proposal from another member 71 | func (m *Member) Accept(member *Member) { 72 | m.acceptedProposalFrom = member 73 | } 74 | 75 | // AcceptMutually marks both this member and another specified member as holding 76 | // accepted proposals from each other. 77 | func (m *Member) AcceptMutually(member *Member) { 78 | m.Accept(member) 79 | member.Accept(m) 80 | } 81 | 82 | // Reject removes the specified member and this member from each others' 83 | // preference lists, and clear's this member's current registered proposer if 84 | // applicable. 85 | // 86 | // Even though this action is two-way (mutual), it only clears this member's 87 | // current registered proposer. Therefore we view this action as "one way", and 88 | // there is a separate `RejectMutually` method that clears both members' 89 | // current registered proposers. 90 | // 91 | // This is needed because from the perspective of this member, it is possible 92 | // to reject another member but still have the other member hold an accepted 93 | // proposal from this member. The algorithm will eventually eliminate this 94 | // pair since at least one of the members has rejected the other, but we don't 95 | // want to change the algorithm internal state pre-maturely. 96 | func (m *Member) Reject(member *Member) { 97 | 98 | // Clear "current proposer" if that's who we're rejecting 99 | if m.CurrentProposer() != nil && m.CurrentProposer().Name() == member.Name() { 100 | m.acceptedProposalFrom = nil 101 | } 102 | 103 | // Remove both members from each other's preference lists 104 | m.preferenceList.Remove(*member) 105 | member.PreferenceList().Remove(*m) 106 | } 107 | 108 | // RejectMutually marks both this member and another specified member as having 109 | // rejected each other. They will both be removed from each others' preference 110 | // lists and both will remove the other as the currently registered proposer 111 | // (if needed). 112 | func (m *Member) RejectMutually(member *Member) { 113 | m.Reject(member) 114 | member.Reject(m) 115 | } 116 | 117 | // WouldPreferProposalFrom indicates whether this member would prefer a new 118 | // proposal from the specified member over a proposal it already holds. 119 | func (m Member) WouldPreferProposalFrom(newProposer Member) bool { 120 | // If there's no proposal accepted, this member will always prefer 121 | // a new proposal 122 | if !m.HasAcceptedProposal() { 123 | return true 124 | } 125 | 126 | idxNew := -1 127 | idxCurrent := -1 128 | 129 | for i := range m.preferenceList.members { 130 | if m.preferenceList.members[i].Name() == newProposer.Name() { 131 | idxNew = i 132 | } 133 | if m.preferenceList.members[i].Name() == m.CurrentProposer().Name() { 134 | idxCurrent = i 135 | } 136 | if idxNew > -1 && idxCurrent > -1 { 137 | break 138 | } 139 | } 140 | 141 | // A lower index means a higher preference. The new proposal is more 142 | // attractive if it's index is less than the current. 143 | return idxNew < idxCurrent 144 | } 145 | 146 | // FirstPreference returns the first preferred member on this member's 147 | // preference list. 148 | func (m Member) FirstPreference() *Member { 149 | if len(m.preferenceList.members) == 0 { 150 | return nil 151 | } 152 | 153 | return m.preferenceList.members[0] 154 | } 155 | 156 | // SecondPreference returns the second preferred member on this member's 157 | // preference list. 158 | func (m Member) SecondPreference() *Member { 159 | if len(m.preferenceList.members) <= 1 { 160 | return nil 161 | } 162 | 163 | return m.preferenceList.members[1] 164 | } 165 | 166 | // LastPreference returns the lowest preferred member on this member's 167 | // preference list. 168 | func (m Member) LastPreference() *Member { 169 | if len(m.preferenceList.members) == 0 { 170 | return nil 171 | } 172 | 173 | return m.preferenceList.members[len(m.preferenceList.members)-1] 174 | } 175 | -------------------------------------------------------------------------------- /pkg/algo/srp/doc.go: -------------------------------------------------------------------------------- 1 | /* 2 | Package smp implements the solution to the "Stable Roommates Problem". 3 | 4 | It implements the Irving (1985) algorithm, which calculates a stable match 5 | within an even-sized set given each member's preference of the other members. 6 | 7 | ALGORITHM 8 | 9 | See: https://en.wikipedia.org/wiki/Stable_roommates_problem 10 | 11 | The algorithm runs in 3 phases. 12 | 13 | 14 | Phase 1 15 | 16 | In this round each member who has not had their proposal accepted "proposes" 17 | to their top remaining preference 18 | 19 | Each recipient of a proposal can take one of 3 actions - 20 | 21 | 1. The recipient has not received a previous proposal and immediately accepts 22 | 23 | 2. The recipient prefers this new proposal over an existing one. 24 | The recipient "rejects" it's initial proposl and accepts this new one 25 | 26 | 3. The recipient prefers the existing proposal over the new one. 27 | The recipient "rejects" the new proposal 28 | 29 | In the above situations, every rejection is mutual - if `i` removes `j` from 30 | its preference list, then `j` must also remove `i` from its list 31 | 32 | This cycle continues until one of two things happens: 33 | 34 | A. Every member has had their proposal accepted (Move on to Phase 2) 35 | 36 | B. At least one member has exhausted their preference list (No solution exists) 37 | 38 | 39 | Phase 2 40 | 41 | In this phase, each member that has accepted a proposal will remove those they 42 | prefer less than their current proposer. 43 | 44 | At the end of one iteration, one of 3 states are possible - 45 | 46 | A. At least one member has exhausted their preference list (No solution exists) 47 | 48 | B. Some members have multiple preferences remaining (Proceed to Phase 3) 49 | 50 | C. All members have one preference remaining (Solution has been found, no need 51 | to run Phase 3) 52 | 53 | 54 | Phase 3 55 | 56 | In this last phase we attempt to find any preference cycles and reject them. 57 | 58 | This is done by building a pair of members (Xi, Yi) as follows 59 | 60 | * Xi is the first member with at least 2 preferences 61 | * Yi is null 62 | * Yi+1 is the 2nd preference of Xi 63 | * Xi+1 is the last preference of Yi+1 64 | 65 | Continue calculating pairs (Xi, Yi) until Xi repeats values. At this point a 66 | cycle has been found. 67 | 68 | We then mutually reject every pair (Xi, Yi) 69 | 70 | After this one of 3 possiblities exists - 71 | 72 | A. At least one member has exhausted their preference list (No solution exists) 73 | 74 | B. Some members have multiple preferences remaining (Repeat the above process to 75 | eliminate further cycles) 76 | 77 | C. All members have one preference remaining (Solution has been found) 78 | 79 | STABILITY AND DETERMINISM 80 | 81 | A stable solution is NOT guranteed. If no further possible proposal exists in 82 | Phase 1 or the reduced preference table results in an empty list in Phase 3, 83 | then no stable solution will exist. 84 | 85 | However, if a solution does exist it is guranteed to be deterministic. That is, 86 | a solution will always converge on one and only one optimial mapping between 87 | members. 88 | 89 | ALGORITHM EXAMPLE 90 | 91 | See: https://www.youtube.com/watch?v=9Lo7TFAkohE 92 | 93 | Take the following preference table 94 | 95 | A => [B, D, F, C, E] 96 | B => [D, E, F, A, C] 97 | C => [D, E, F, A, B] 98 | D => [F, C, A, E, B] 99 | E => [F, C, D, B, A] 100 | F => [A, B, D, C, E] 101 | 102 | We always start with the first unmatched user. Initially this is "A". 103 | 104 | The sequence of events are - 105 | 106 | 'A' proposes to 'B' 107 | 'B' accepts 'A' 108 | 'B' proposes to 'D' 109 | 'D' accepts 'B' 110 | 'C' proposes to 'D' 111 | 'D' accepts 'C', rejects 'B' 112 | 'B' proposes to 'E' 113 | 'E' accepts 'B' 114 | 'D' proposes to 'F' 115 | 'F' accepts 'D' 116 | 'E' proposes to 'F' 117 | 'F' rejects 118 | 'E' proposes to 'C' 119 | 'C' accepts 'E' 120 | 'F' proposes to 'A' 121 | 'A' accepts 'F' 122 | 123 | The result of Phase 1 is shown below. A "-" indicates a proposal made and a "+" 124 | indicates a proposal accepted. Rejected members are removed. 125 | 126 | A => [-B, D, +F, C, E] 127 | B => [-E, F, +A, C] 128 | C => [-D, +E, F, A,B] 129 | D => [-F, +C, A, E] 130 | E => [-C, D, +B, A] 131 | F => [-A, B, +D, C] 132 | 133 | Phase 2 rejects occur as follows. Note that all rejections are 134 | mutual - if `i` removes `j` from its preference list, then `j` must also 135 | remove `i` from its list 136 | 137 | 'A' accepted by 'B'. 'B' rejecting members less preferred than 'A': ["C"] 138 | 'B' accepted by 'E'. 'E' rejecting members less preferred than 'B': ["A"] 139 | 'C' accepted by 'D'. 'D' rejecting members less preferred than 'C': ["A", "E"] 140 | 'D' accepted by 'F'. 'F' rejecting members less preferred than 'D': ["C"] 141 | 'E' accepted by 'C'. 'C' rejecting members less preferred than 'E': ["A"] 142 | 'F' accepted by 'A'. 'A' rejecting members less preferred than 'F': [] 143 | 144 | The output of this phase is a further reduced table is as follows 145 | 146 | A => [B, F] 147 | B => [E, F, A] 148 | C => [D, E] 149 | D => [F, C] 150 | E => [C, B], 151 | F => [A, B, D] 152 | 153 | Since at least one member has multiple preferences remaining, we proceed to 154 | Phase 3. 155 | 156 | Phase 3 starts with "A" since it is the first member with at least two 157 | preferences. 158 | 159 | Build (Xi, Yi) pairs as follows 160 | 161 | i 1 2 3 4 162 | -----+---+---+---+---- 163 | x: | A | D | E | A 164 | y: | - | F | C | B 165 | 166 | Where - 167 | 168 | 'F' is the 2nd preference of 'A' 169 | 'D' is the last preference of 'F' 170 | 'C' is the 2nd preference of 'D' 171 | etc... 172 | 173 | As soon as we see "A" again, we stop since we have found a cycle. 174 | 175 | Now we will mutually reject the following pairs, as definied by the inner 176 | pairings 177 | 178 | (D, F) 179 | (E, C) 180 | (A, B) 181 | 182 | At this point, no preference list is exhausted and some have more than one 183 | preference remaining. We need to find and eliminate more preference cycles. 184 | 185 | i 1 2 186 | -----+---+--- 187 | x: | B | B 188 | y: | - | F 189 | 190 | Now we will mutually reject 191 | 192 | (F, B) 193 | 194 | This gives us the stable solution below, since each roommate has exactly one 195 | preference remaining 196 | 197 | A => F 198 | B => E 199 | C => D 200 | D => C 201 | E => B 202 | F => A 203 | */ 204 | package srp 205 | -------------------------------------------------------------------------------- /libmatch.go: -------------------------------------------------------------------------------- 1 | /* 2 | Package libmatch provides an API for solving matching problems. 3 | 4 | Each matching algorithm has a shorthand acronym that can be used to invoke 5 | the solver. For example "Stable Marriage Problem" has a shorthand of "SMP" and 6 | to invoke the solver, you can call: 7 | 8 | libmatch.SolveSMP(...) 9 | 10 | For a full list of available matching algorithms and their shorthands, see 11 | https://github.com/abhchand/libmatch#readme. 12 | 13 | If you have the libmatch command line utility installed, you can also run 14 | 15 | libmatch ls 16 | 17 | to see this list. 18 | */ 19 | package libmatch 20 | 21 | import ( 22 | "io" 23 | 24 | "github.com/abhchand/libmatch/pkg/algo/smp" 25 | "github.com/abhchand/libmatch/pkg/algo/srp" 26 | "github.com/abhchand/libmatch/pkg/core" 27 | "github.com/abhchand/libmatch/pkg/load" 28 | "github.com/abhchand/libmatch/pkg/validate" 29 | ) 30 | 31 | type MatchPreference = core.MatchPreference 32 | type MatchResult = core.MatchResult 33 | 34 | // Load reads match preference data from an `io.Reader`. 35 | // 36 | // The expected data is a JSON formatted preference table of the format: 37 | // 38 | // [ 39 | // { "name":"A", "preferences": ["B", "D", "F", "C", "E"] }, 40 | // { "name":"B", "preferences": ["D", "E", "F", "A", "C"] }, 41 | // { "name":"C", "preferences": ["D", "E", "F", "A", "B"] }, 42 | // { "name":"D", "preferences": ["F", "C", "A", "E", "B"] }, 43 | // { "name":"E", "preferences": ["F", "C", "D", "B", "A"] }, 44 | // { "name":"F", "preferences": ["A", "B", "D", "C", "E"] }, 45 | // ] 46 | // 47 | // The return value is an array of `MatchPreference` structs containing the 48 | // loaded JSON data 49 | // 50 | // *[]libmatch.MatchPreference{ 51 | // {Name: "A", Preferences: []string{"B", "D", "F", "C", "E"}}, 52 | // {Name: "B", Preferences: []string{"D", "E", "F", "A", "C"}}, 53 | // {Name: "C", Preferences: []string{"D", "E", "F", "A", "B"}}, 54 | // {Name: "D", Preferences: []string{"F", "C", "A", "E", "B"}}, 55 | // {Name: "E", Preferences: []string{"F", "C", "D", "B", "A"}}, 56 | // {Name: "F", Preferences: []string{"A", "B", "D", "C", "E"}}, 57 | // } 58 | func Load(r io.Reader) (*[]MatchPreference, error) { 59 | mp, err := load.LoadFromIO(r) 60 | if err != nil { 61 | return mp, err 62 | } 63 | 64 | return mp, err 65 | } 66 | 67 | // SolveSMP solves the Stable Marriage Problem for a set of preferences. 68 | // 69 | // See: https://en.wikipedia.org/wiki/Stable_marriage_problem 70 | // 71 | // The algorithm finds a stable matching between two same-sized sets. 72 | // Implements the Gale-Shapley (1962) algorithm. A stable solution is always 73 | // guranteed, but it is non-deterministic and potentially one of many. 74 | // 75 | // Example: 76 | // 77 | // SolveSMP takes a pair of preference tables as inputs. Each preference table 78 | // is an array of match preferences. 79 | // 80 | // prefTableA := []libmatch.MatchPreference{ 81 | // {Name: "A", Preferences: []string{"F", "J", "H", "G", "I"}}, 82 | // {Name: "B", Preferences: []string{"F", "J", "H", "G", "I"}}, 83 | // {Name: "C", Preferences: []string{"F", "G", "H", "J", "I"}}, 84 | // {Name: "D", Preferences: []string{"H", "J", "F", "I", "G"}}, 85 | // {Name: "E", Preferences: []string{"H", "F", "G", "I", "J"}}, 86 | // } 87 | // 88 | // prefTableB := []libmatch.MatchPreference{ 89 | // {Name: "F", Preferences: []string{"A", "E", "C", "B", "D"}}, 90 | // {Name: "G", Preferences: []string{"D", "E", "C", "B", "A"}}, 91 | // {Name: "H", Preferences: []string{"A", "B", "C", "D", "E"}}, 92 | // {Name: "I", Preferences: []string{"B", "E", "C", "D", "A"}}, 93 | // {Name: "J", Preferences: []string{"E", "A", "D", "B", "C"}}, 94 | // } 95 | // 96 | // On success, the return value will be a MatchResult containing the stable the 97 | // mapping between pairs of members. 98 | // 99 | // MatchResult{ 100 | // Mapping: map[string]string{ 101 | // "A": "F", 102 | // "B": "H", 103 | // "C": "I", 104 | // "D": "J", 105 | // "E": "G", 106 | // "F": "A", 107 | // "G": "E", 108 | // "H": "B", 109 | // "I": "C", 110 | // "J": "D", 111 | // }, 112 | // } 113 | func SolveSMP(prefsA, prefsB *[]MatchPreference) (MatchResult, error) { 114 | var res MatchResult 115 | var err error 116 | 117 | tables := core.NewPreferenceTablePair(prefsA, prefsB) 118 | validator := validate.DoubleTableValidator{ 119 | PrefsSet: []*[]core.MatchPreference{prefsA, prefsB}, 120 | Tables: []*core.PreferenceTable{&tables[0], &tables[1]}, 121 | } 122 | 123 | if err = validator.Validate(); err != nil { 124 | return res, err 125 | } 126 | 127 | algoCtx := core.AlgorithmContext{ 128 | TableA: &tables[0], 129 | TableB: &tables[1], 130 | } 131 | 132 | res, err = smp.Run(algoCtx) 133 | 134 | return res, err 135 | } 136 | 137 | // SolveSRP solves the Stable Roommates Problem for a set of preferences. 138 | // 139 | // See: https://en.wikipedia.org/wiki/Stable_roommates_problem 140 | // 141 | // The algorithm finds a stable matching within an even-sized set. A stable 142 | // solution is not guranteed, but is always deterministic if exists. 143 | // Implements Irving's (1985) algorithm. 144 | // 145 | // Example: 146 | // 147 | // SolveSRP takes a single preference table as an input. The preference table 148 | // is an array of match preferences. 149 | // 150 | // prefs := *[]libmatch.MatchPreference{ 151 | // {Name: "A", Preferences: []string{"B", "D", "F", "C", "E"}}, 152 | // {Name: "B", Preferences: []string{"D", "E", "F", "A", "C"}}, 153 | // {Name: "C", Preferences: []string{"D", "E", "F", "A", "B"}}, 154 | // {Name: "D", Preferences: []string{"F", "C", "A", "E", "B"}}, 155 | // {Name: "E", Preferences: []string{"F", "C", "D", "B", "A"}}, 156 | // {Name: "F", Preferences: []string{"A", "B", "D", "C", "E"}} 157 | // } 158 | // 159 | // On success, the return value will be a MatchResult containing the stable the 160 | // mapping between pairs of members. 161 | // 162 | // MatchResult{ 163 | // Mapping: map[string]string{ 164 | // "A": "F", 165 | // "B": "E", 166 | // "C": "D", 167 | // "D": "C", 168 | // "E": "B", 169 | // "F": "A", 170 | // }, 171 | // } 172 | func SolveSRP(prefs *[]MatchPreference) (MatchResult, error) { 173 | var res MatchResult 174 | var err error 175 | 176 | table := core.NewPreferenceTable(prefs) 177 | validator := validate.SingleTableValidator{Prefs: prefs, Table: &table} 178 | 179 | if err = validator.Validate(); err != nil { 180 | return res, err 181 | } 182 | 183 | algoCtx := core.AlgorithmContext{ 184 | TableA: &table, 185 | } 186 | 187 | res, err = srp.Run(algoCtx) 188 | 189 | return res, err 190 | } 191 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
14 | 15 | --- 16 | 17 | [![Build Status][ci-badge]][ci] 18 | 19 | `libmatch` can be used as a **Go package** ([docs](https://pkg.go.dev/github.com/abhchand/libmatch)) or as a **standalone executable** (CLI). 20 | 21 | It supports solving the following problems: 22 | 23 | | Matching Problem | Shorthand | Description | 24 | |---|---|---| 25 | | [Stable Marriage Problem](https://en.wikipedia.org/wiki/Stable_marriage_problem) | `SMP` | Matching between two groups of members | 26 | | [Stable Roommates Problem](https://en.wikipedia.org/wiki/Stable_roommates_problem) | `SRP` | Matching within a group of members | 27 | 28 | --- 29 | 30 | - [What Does This Do?](#what-does-this-do) 31 | - [Go Package](#go-package) 32 | * [Installation](#pkg-installation) 33 | * [Examples](#pkg-examples) 34 | * [Stable Marriage Example](#pkg-stable-marriage-example) 35 | * [Stable Roommates Example](#pkg-stable-roommates-example) 36 | - [CLI](#cli) 37 | * [Installation](#cliinstallation) 38 | * [Examples](#cli-examples) 39 | * [Stable Marriage Example](#cli-stable-marriage-example) 40 | * [Stable Roommates Example](#cli-stable-roommates-example) 41 | - [Miscellaneous](#miscellaneous) 42 | 43 | 44 | ## What Does This Do? 45 | 46 | Matching algorithms find an optimal matching between members, given one or more sets of *member preferences*. 47 | 48 |
50 |