uniqueCities = new HashMap<>();
+ for (City city : cities) {
+ if (city.getName() == null || !StringUtils.hasText(city.getName())) {
+ log.warn("Skipping invalid city: empty name {}", city);
+ continue;
+ }
+
+ final City old = uniqueCities.put(city.getName(), city);
+ if (old != null) {
+ log.warn("Skipping invalid city: duplicate {}", city);
+ }
+ }
+ return uniqueCities.values();
+ }
+
+ /**
+ * Build unique city name using country variations.
+ *
+ * This should be really generalized, but for simplicity just hardcode special behavior for US and CA.
+ *
+ * @param input data
+ * @return unique city name, can be used as a key for now.
+ */
+ private static City buildUniqueCityName(String[] input) {
+ final long id = Long.parseLong(input[ID_IDX]);
+ final String country = input[COUNTRY_IDX];
+ final String shortName = input[NAME_IDX];
+ final String fullName = shortName + ", " +
+ (country.equals(CANADA) ?
+ CanadianProvince.ofFipsCode(Integer.parseInt(input[STATE_IDX])).name() :
+ input[STATE_IDX])
+ + ", " + country;
+ return City.of(id, shortName, fullName, new BigDecimal(input[LATITUDE_IDX]),
+ new BigDecimal(input[LONGITUDE_IDX]));
+ }
+}
diff --git a/src/main/java/com/coveo/challenge/cities/impl/scoring/ScoringServiceImpl.java b/src/main/java/com/coveo/challenge/cities/impl/scoring/ScoringServiceImpl.java
new file mode 100644
index 000000000..7d65e3f1f
--- /dev/null
+++ b/src/main/java/com/coveo/challenge/cities/impl/scoring/ScoringServiceImpl.java
@@ -0,0 +1,88 @@
+package com.coveo.challenge.cities.impl.scoring;
+
+import com.coveo.challenge.cities.api.ScoringService;
+import com.coveo.challenge.cities.model.City;
+import com.coveo.challenge.cities.model.Query;
+import com.coveo.challenge.cities.model.Suggestion;
+import org.springframework.stereotype.Service;
+
+import javax.validation.constraints.NotNull;
+import java.math.BigDecimal;
+import java.math.MathContext;
+import java.math.RoundingMode;
+import java.util.List;
+import java.util.stream.Collectors;
+
+@Service
+public class ScoringServiceImpl implements ScoringService {
+
+ /**
+ * Scaled used in score calculations.
+ */
+ private static final int SCALE = 2;
+
+ /**
+ * Margin distance for a city to be relevant, 40 degrees.
+ */
+ private static final BigDecimal MAX_RELEVANT_DISTANCE = BigDecimal.valueOf(40);
+
+ @Override
+ public List evaluate(Query query, List cities) {
+ return cities.stream().map(city -> new Suggestion(city, computeScore(query, city))).collect(Collectors.toList());
+ }
+
+ private BigDecimal computeScore(Query query, City city) {
+ var nameScore = computeNameScore(query.getQueryString(), city.getShortName());
+ if (query.getLatitude() == null || query.getLongitude() == null) {
+ return nameScore;
+ }
+ var coordinateScore = computeCoordinateScore(query.getLatitude(), query.getLongitude(), city.getLatitude(),
+ city.getLongitude());
+
+ return nameScore.add(coordinateScore).divide(BigDecimal.valueOf(2L), 1, RoundingMode.CEILING);
+ }
+
+ /**
+ * @param requestedLatitude requested coordinate
+ * @param requestedLongitude requested coordinate
+ * @param actualLatitude of the city
+ * @param actualLongitude of the city
+ * @return value between 0 and 1
+ */
+ private BigDecimal computeCoordinateScore(@NotNull BigDecimal requestedLatitude,
+ @NotNull BigDecimal requestedLongitude,
+ @NotNull BigDecimal actualLatitude, @NotNull BigDecimal actualLongitude) {
+ // TODO normalize
+ var latitudeDiffSquared = computeDiffSquared(requestedLatitude, actualLatitude);
+ var longitudeDiffSquared = computeDiffSquared(requestedLongitude, actualLongitude);
+ var distance = latitudeDiffSquared.add(longitudeDiffSquared)
+ .sqrt(MathContext.DECIMAL32)
+ .setScale(1, RoundingMode.CEILING);
+ if (distance.compareTo(MAX_RELEVANT_DISTANCE) > 0) {
+ return BigDecimal.ZERO;
+ }
+ return BigDecimal.ZERO.max(BigDecimal.ONE.subtract(distance.divide(MAX_RELEVANT_DISTANCE, RoundingMode.FLOOR).sqrt(MathContext.DECIMAL32)));
+ }
+
+ private BigDecimal computeDiffSquared(BigDecimal requestedLatitude, BigDecimal actualLatitude) {
+ var diff = requestedLatitude.subtract(actualLatitude).abs();
+ if (diff.compareTo(BigDecimal.valueOf(180L)) > 0) {
+ // add handling of cases like +179 and -177 (diff = 4, not 354)
+ throw new UnsupportedOperationException("This diff in coordinates not yet implemented: " + requestedLatitude + " : " + actualLatitude);
+ }
+ return diff.pow(2);
+ }
+
+ /**
+ * @param queryString name queried
+ * @param name of the city
+ * @return value between 0 and 1
+ */
+ private BigDecimal computeNameScore(String queryString, String name) {
+ if (!name.startsWith(queryString)) {
+ throw new IllegalStateException("Programming error: name is supposed to start with queryString here");
+ }
+ return BigDecimal.valueOf(queryString.length()).divide(BigDecimal.valueOf(name.length()), SCALE,
+ RoundingMode.CEILING);
+ }
+}
diff --git a/src/main/java/com/coveo/challenge/cities/impl/search/SearchServiceImpl.java b/src/main/java/com/coveo/challenge/cities/impl/search/SearchServiceImpl.java
new file mode 100644
index 000000000..827a401ed
--- /dev/null
+++ b/src/main/java/com/coveo/challenge/cities/impl/search/SearchServiceImpl.java
@@ -0,0 +1,39 @@
+package com.coveo.challenge.cities.impl.search;
+
+import com.coveo.challenge.cities.api.SearchService;
+import com.coveo.challenge.cities.model.City;
+import lombok.RequiredArgsConstructor;
+import org.springframework.stereotype.Service;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.NavigableMap;
+
+@Service
+@RequiredArgsConstructor
+public class SearchServiceImpl implements SearchService {
+
+ private final NavigableMap data;
+
+ @Override
+ public List search(String queryString) {
+ final City city = data.get(queryString);
+ if (city != null) {
+ return List.of(city);
+ }
+ final var toKey = buildToKey(queryString);
+ return new ArrayList<>(data.subMap(queryString, toKey).values());
+ }
+
+ /**
+ * Build exclusive to-key by incrementing last character by one,
+ *
+ * @param fromKey start of the interval
+ * @return end of the interval
+ */
+ private String buildToKey(String fromKey) {
+ final char[] chars = fromKey.toCharArray().clone();
+ chars[chars.length - 1] = (char) (chars[chars.length - 1] + (byte) 1);
+ return new String(chars);
+ }
+}
diff --git a/src/main/java/com/coveo/challenge/cities/impl/suggest/SuggestionServiceImpl.java b/src/main/java/com/coveo/challenge/cities/impl/suggest/SuggestionServiceImpl.java
new file mode 100644
index 000000000..1943143e3
--- /dev/null
+++ b/src/main/java/com/coveo/challenge/cities/impl/suggest/SuggestionServiceImpl.java
@@ -0,0 +1,31 @@
+package com.coveo.challenge.cities.impl.suggest;
+
+import com.coveo.challenge.cities.api.ScoringService;
+import com.coveo.challenge.cities.api.SearchService;
+import com.coveo.challenge.cities.api.SuggestionService;
+import com.coveo.challenge.cities.model.Query;
+import com.coveo.challenge.cities.model.Suggestion;
+import lombok.RequiredArgsConstructor;
+import org.springframework.stereotype.Service;
+
+import java.util.Comparator;
+import java.util.List;
+import java.util.stream.Collectors;
+
+@Service
+@RequiredArgsConstructor
+public class SuggestionServiceImpl implements SuggestionService {
+
+ private final SearchService searchService;
+
+ private final ScoringService scoringService;
+
+ @Override
+ public List getSuggestion(Query query) {
+ return scoringService.evaluate(query, searchService.search(query.getQueryString()))
+ .stream()
+ // from 1 to 0
+ .sorted(Comparator.comparing(Suggestion::score).reversed())
+ .collect(Collectors.toList());
+ }
+}
diff --git a/src/main/java/com/coveo/challenge/cities/model/City.java b/src/main/java/com/coveo/challenge/cities/model/City.java
new file mode 100644
index 000000000..0bde0ac26
--- /dev/null
+++ b/src/main/java/com/coveo/challenge/cities/model/City.java
@@ -0,0 +1,41 @@
+package com.coveo.challenge.cities.model;
+
+import com.fasterxml.jackson.annotation.JsonIgnore;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+
+import java.math.BigDecimal;
+
+@Data(staticConstructor = "of")
+@EqualsAndHashCode(onlyExplicitlyIncluded = true)
+public class City {
+ @JsonIgnore
+ private final long id;
+ /**
+ * Name which is used for search.
+ */
+ @JsonIgnore
+ private final String shortName;
+ /**
+ * Full unique name to display.
+ */
+ @EqualsAndHashCode.Include
+ private final String name;
+ private final BigDecimal latitude;
+ private final BigDecimal longitude;
+
+
+ /**
+ * Factory method to be used when short and full name are the same, e.g. in tests.
+ */
+ public static City of(long id, String name, BigDecimal latitude, BigDecimal longitude) {
+ return of(id, name, name, latitude, longitude);
+ }
+
+ /**
+ * Factory method to be used when short and full name are the same, e.g. in tests.
+ */
+ public static City of(long id, String name) {
+ return of(id, name, null, null);
+ }
+}
diff --git a/src/main/java/com/coveo/challenge/cities/model/Query.java b/src/main/java/com/coveo/challenge/cities/model/Query.java
new file mode 100644
index 000000000..8ed7cba43
--- /dev/null
+++ b/src/main/java/com/coveo/challenge/cities/model/Query.java
@@ -0,0 +1,29 @@
+package com.coveo.challenge.cities.model;
+
+import lombok.Data;
+
+import java.math.BigDecimal;
+
+/**
+ * Query to find cities.
+ *
+ * Latitude and longitude are considered only it both provided.
+ */
+@Data
+public class Query {
+
+ /**
+ * String city name must start with.
+ */
+ private final String queryString;
+
+ /**
+ * Optional latitude. Ignored if no latitude provided.
+ */
+ private final BigDecimal latitude;
+
+ /**
+ * Optional longitude. Ignored if no longitude provided.
+ */
+ private final BigDecimal longitude;
+}
diff --git a/src/main/java/com/coveo/challenge/cities/model/Suggestion.java b/src/main/java/com/coveo/challenge/cities/model/Suggestion.java
new file mode 100644
index 000000000..d8cffaa77
--- /dev/null
+++ b/src/main/java/com/coveo/challenge/cities/model/Suggestion.java
@@ -0,0 +1,13 @@
+package com.coveo.challenge.cities.model;
+
+import com.fasterxml.jackson.annotation.JsonUnwrapped;
+
+import java.math.BigDecimal;
+
+/**
+ * @param city City found.
+ * @param score Match evaluation, from 0 to 1 (best match).
+ */
+public record Suggestion(@JsonUnwrapped City city, BigDecimal score) {
+
+}
diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties
new file mode 100644
index 000000000..7e999c5fc
--- /dev/null
+++ b/src/main/resources/application.properties
@@ -0,0 +1 @@
+coveo.filepath=data/cities_canada-usa.tsv
diff --git a/src/test/java/com/coveo/challenge/cities/CitiesApplicationTests.java b/src/test/java/com/coveo/challenge/cities/CitiesApplicationTests.java
new file mode 100644
index 000000000..7bf363e5c
--- /dev/null
+++ b/src/test/java/com/coveo/challenge/cities/CitiesApplicationTests.java
@@ -0,0 +1,13 @@
+package com.coveo.challenge.cities;
+
+import org.junit.jupiter.api.Test;
+import org.springframework.boot.test.context.SpringBootTest;
+
+@SpringBootTest
+class CitiesApplicationTests {
+
+ @Test
+ void contextLoads() {
+ }
+
+}
diff --git a/src/test/java/com/coveo/challenge/cities/impl/scoring/ScoringServiceImplTest.java b/src/test/java/com/coveo/challenge/cities/impl/scoring/ScoringServiceImplTest.java
new file mode 100644
index 000000000..8c40fff3d
--- /dev/null
+++ b/src/test/java/com/coveo/challenge/cities/impl/scoring/ScoringServiceImplTest.java
@@ -0,0 +1,125 @@
+package com.coveo.challenge.cities.impl.scoring;
+
+import com.coveo.challenge.cities.api.ScoringService;
+import com.coveo.challenge.cities.model.City;
+import com.coveo.challenge.cities.model.Query;
+import com.coveo.challenge.cities.model.Suggestion;
+import org.junit.jupiter.api.Test;
+
+import java.math.BigDecimal;
+import java.math.RoundingMode;
+import java.util.List;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+class ScoringServiceImplTest {
+
+ private final ScoringService scoringService = new ScoringServiceImpl();
+
+ @Test
+ void testNoCities() {
+ // given a query
+ Query query = new Query("Londo", null, null);
+
+ // when scoring empty list
+ final List suggestions = scoringService.evaluate(query, List.of());
+
+ // then empty list
+ assertNotNull(suggestions);
+ assertTrue(suggestions.isEmpty());
+ }
+
+ @Test
+ void testWrongName() {
+ // given a query with a name
+ Query query = new Query("Londo", null, null);
+
+ // when scoring city with non-matching name
+ // then exception thrown
+ assertThrows(IllegalStateException.class, () ->
+ scoringService.evaluate(query, List.of(City.of(1L, "St. Petersburg",
+ BigDecimal.ONE, BigDecimal.TEN))));
+ }
+
+ @Test
+ void testPartialMatchNoCoordinates() {
+ // given query without coordinates
+ Query query = new Query("London", null, null);
+
+ // when scoring exact match name
+ final List suggestions = scoringService.evaluate(query, List.of(City.of(1L, "Londonderry",
+ BigDecimal.ONE, BigDecimal.TEN)));
+
+ // then best score 1
+ assertNotNull(suggestions);
+ assertEquals(1, suggestions.size());
+ final Suggestion suggestion = suggestions.get(0);
+ final BigDecimal expected = BigDecimal.valueOf(6).divide(BigDecimal.valueOf(11), 2, RoundingMode.CEILING);
+ assertEquals(expected, suggestion.score());
+ }
+
+ @Test
+ void testExactMatchNoCoordinates() {
+ // given query without coordinates
+ Query query = new Query("St. Petersburg", null, null);
+
+ // when scoring exact match name
+ final List suggestions = scoringService.evaluate(query, List.of(City.of(1L, "St. Petersburg",
+ BigDecimal.ONE, BigDecimal.TEN)));
+
+ // then best score 1
+ assertNotNull(suggestions);
+ assertEquals(1, suggestions.size());
+ final Suggestion suggestion = suggestions.get(0);
+ assertEquals(0, BigDecimal.ONE.compareTo(suggestion.score()));
+ }
+
+ @Test
+ void testExactMatchFullnameDiffer() {
+ // given query without coordinates
+ Query query = new Query("St. Petersburg", null, null);
+
+ // when scoring exact short name, but fullname has appendix
+ final List suggestions = scoringService.evaluate(query, List.of(City.of(1L, "St. Petersburg",
+ "St. Petersburg, Russia",
+ BigDecimal.ONE, BigDecimal.TEN)));
+
+ // then best score should be 1, because only short name matters
+ assertNotNull(suggestions);
+ assertEquals(1, suggestions.size());
+ final Suggestion suggestion = suggestions.get(0);
+ assertEquals(0, BigDecimal.ONE.compareTo(suggestion.score()));
+ }
+
+ @Test
+ void testExactMatchNeighborhood() {
+ // given query without coordinates
+ Query query = new Query("St. Petersburg", BigDecimal.valueOf(60), BigDecimal.valueOf(30));
+
+ // when scoring exact match name and coordinate diff smaller than 0.1
+ final List suggestions = scoringService.evaluate(query, List.of(City.of(1L, "St. Petersburg",
+ BigDecimal.valueOf(60.03), BigDecimal.valueOf(29.07))));
+
+ // then best score 1
+ assertNotNull(suggestions);
+ assertEquals(1, suggestions.size());
+ final Suggestion suggestion = suggestions.get(0);
+ assertEquals(0, BigDecimal.ONE.compareTo(suggestion.score()));
+ }
+
+ @Test
+ void testExactMatchAntipode() {
+ // given query without coordinates
+ Query query = new Query("St. Petersburg", BigDecimal.valueOf(60), BigDecimal.valueOf(30));
+
+ // when scoring exact match name and coordinate diff smaller than 0.1
+ final List suggestions = scoringService.evaluate(query, List.of(City.of(1L, "St. Petersburg",
+ BigDecimal.valueOf(60 - 180), BigDecimal.valueOf(30 - 180))));
+
+ // then best score 1
+ assertNotNull(suggestions);
+ assertEquals(1, suggestions.size());
+ final Suggestion suggestion = suggestions.get(0);
+ assertEquals(BigDecimal.valueOf(5, 1), suggestion.score());
+ }
+}
\ No newline at end of file
diff --git a/src/test/java/com/coveo/challenge/cities/impl/suggest/SuggestionServiceImplTest.java b/src/test/java/com/coveo/challenge/cities/impl/suggest/SuggestionServiceImplTest.java
new file mode 100644
index 000000000..3a675c046
--- /dev/null
+++ b/src/test/java/com/coveo/challenge/cities/impl/suggest/SuggestionServiceImplTest.java
@@ -0,0 +1,58 @@
+package com.coveo.challenge.cities.impl.suggest;
+
+import com.coveo.challenge.cities.api.ScoringService;
+import com.coveo.challenge.cities.api.SearchService;
+import com.coveo.challenge.cities.model.City;
+import com.coveo.challenge.cities.model.Query;
+import com.coveo.challenge.cities.model.Suggestion;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+
+import java.math.BigDecimal;
+import java.util.List;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.mockito.Mockito.when;
+
+@ExtendWith(MockitoExtension.class)
+class SuggestionServiceImplTest {
+
+ @InjectMocks
+ private SuggestionServiceImpl suggestionService;
+
+ @Mock
+ private SearchService searchService;
+
+ @Mock
+ private ScoringService scoringService;
+
+ @Test
+ void bestMatchFirst() {
+ // given multiple matches with diverse score
+ Query query = new Query("Brigh", null, null);
+
+ final City brighton = City.of(1L, "Brighton");
+ final City bright = City.of(1L, "Bright");
+ final City brigham_city = City.of(1L, "Brigham City");
+ List cities = List.of(brighton, bright, brigham_city);
+ when(searchService.search(query.getQueryString())).thenReturn(cities);
+
+ List suggestions = List.of(
+ new Suggestion(brighton, BigDecimal.ONE),
+ new Suggestion(brigham_city, BigDecimal.ZERO),
+ new Suggestion(bright, BigDecimal.TEN));
+ when(scoringService.evaluate(query, cities)).thenReturn(suggestions);
+
+ // when sorting
+ suggestions = suggestionService.getSuggestion(query);
+
+ // then higher score comes first
+ assertEquals(3, suggestions.size());
+ assertEquals(BigDecimal.TEN, suggestions.get(0).score());
+ assertEquals(BigDecimal.ONE, suggestions.get(1).score());
+ assertEquals(BigDecimal.ZERO, suggestions.get(2).score());
+ }
+}
\ No newline at end of file
diff --git a/src/test/java/com/coveo/challenge/cities/search/SearchServiceImplTest.java b/src/test/java/com/coveo/challenge/cities/search/SearchServiceImplTest.java
new file mode 100644
index 000000000..09386e289
--- /dev/null
+++ b/src/test/java/com/coveo/challenge/cities/search/SearchServiceImplTest.java
@@ -0,0 +1,89 @@
+package com.coveo.challenge.cities.search;
+
+import com.coveo.challenge.cities.impl.search.SearchServiceImpl;
+import com.coveo.challenge.cities.model.City;
+import org.junit.jupiter.api.Test;
+
+import java.math.BigDecimal;
+import java.util.Collections;
+import java.util.List;
+import java.util.NavigableMap;
+import java.util.Set;
+import java.util.TreeMap;
+import java.util.stream.Collectors;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+
+class SearchServiceImplTest {
+
+ @Test
+ void searchExact() {
+ // given a full map
+ SearchServiceImpl searchService = prepareLondons();
+
+ // when searching for the exact match
+ final List cities = searchService.search("London, KY, USA");
+
+ // then just one result - the exact match
+ assertNotNull(cities);
+ assertEquals(1, cities.size());
+ assertEquals(City.of(3L, "London, KY, USA", BigDecimal.valueOf(37.12898d), BigDecimal.valueOf(-84.08326d)),
+ cities.get(0));
+ }
+
+ @Test
+ void searchWildcardMultiple() {
+ // given a full map
+ SearchServiceImpl searchService = prepareLondons();
+
+ // when searching for a wildcard and there are two matches
+ final List cities = searchService.search("London, O");
+
+ // then the result is the two matches
+ assertNotNull(cities);
+ assertEquals(2, cities.size());
+ final Set cityNames = cities.stream().map(City::getName).collect(Collectors.toSet());
+ assertEquals(Set.of("London, ON, Canada", "London, OH, USA"), cityNames);
+ }
+
+ @Test
+ void searchWildcardSingle() {
+ // given a full map
+ SearchServiceImpl searchService = prepareLondons();
+
+ // when searching for a wildcard and there is only one partly match
+ final List cities = searchService.search("London, ON");
+
+ // then the result is the two matches
+ assertNotNull(cities);
+ assertEquals(1, cities.size());
+ final Set cityNames = cities.stream().map(City::getName).collect(Collectors.toSet());
+ assertEquals(Set.of("London, ON, Canada"), cityNames);
+ }
+
+ private SearchServiceImpl prepareLondons() {
+ NavigableMap data = new DataBuilder()
+ .add(City.of(1L, "London, ON, Canada", BigDecimal.valueOf(42.98339d), BigDecimal.valueOf(-81.23304d)))
+ .add(City.of(2L, "London, OH, USA", BigDecimal.valueOf(39.88645d), BigDecimal.valueOf(-83.44825d)))
+ .add(City.of(3L, "London, KY, USA", BigDecimal.valueOf(37.12898d), BigDecimal.valueOf(-84.08326d)))
+ .add(City.of(4L, "Londontowne, MD, USA", BigDecimal.valueOf(38.93345d), BigDecimal.valueOf(-76.54941d)))
+ .build();
+
+ return new SearchServiceImpl(data);
+ }
+
+ private static class DataBuilder {
+
+ private final TreeMap data = new TreeMap<>();
+
+ public DataBuilder add(City city) {
+ data.put(city.getName(), city);
+ return this;
+ }
+
+ public NavigableMap build() {
+ return Collections.unmodifiableNavigableMap(data);
+ }
+ }
+}
\ No newline at end of file
diff --git a/system.properties b/system.properties
new file mode 100644
index 000000000..0dc726cec
--- /dev/null
+++ b/system.properties
@@ -0,0 +1 @@
+java.runtime.version=17
\ No newline at end of file