Introduction to Collaborative Filtering Recommender

Hệ thống khuyến nghị (Recommendation System) là một Engine được sử dụng phổ biến ở nhiều công ti thương mại điện tử. Collaborative Filtering Recommender (CFR) là một cách tiếp cận (mô hình) dựa trên dữ liệu lịch sử về hành vi mua sắm của khách hàng với ý tưởng rằng nếu, ví dụ, hai khách hàng cùng mua một cuốn sách (hoặc xem cùng một bộ phim) thì rất có thể họ sẽ tái diễn pattern đó trong tương lai. Hoặc hai khách hàng A và B được cho là có hành vi mua sắm tương tự nhau thì nếu khách hàng A đã xem một bộ phim X nào đó nhưng B thì lại chưa xem bộ phim này thì nếu hệ thống gợi ý rằng nên giới thiệu phim X này cho khách hàng B. Cách tiếp cận này rõ ràng chỉ dựa trên sở thích của khách hàng (user preferences) chứ không dựa trên những đặc điểm hay nội dung (contents) của sách/phim để đưa ra khuyến nghị cho người dùng/khách hàng tiềm năng.

Bài viết này minh họa việc xây dựng CFR bằng ngôn ngữ R với bộ dữ liệu từ MovieLens (phiên bản small) bao gồm 105339 ratings của 10329 bộ phim từ 668 user trong khoảng thời gian từ April 03, 1996 đến January 09, 2016. Bộ dữ liệu này có thể download ở đây. Định hướng thực hành nên các lí thuyết và cơ sở toán học về Recommendation System sẽ không được trình bày ở đây. Bạn đọc được giả định là đã có những hiểu biết nhất định về Recommendation System.

Data pre-processing and Exploratory Data Analysis

Tiền xử lí số liệu (Data pre-processing) và phân tích khám phá dữ liệu EDA (Exploratory Data Analysis) là bước luôn phải được thực hiện đầu tiên của một data project. Trước hết đọc hai files dữ liệu movies.csvratings.csv:

# Clear workspace: 

rm(list = ls())

# Load data: 

library(tidyverse)

movies <- read_csv("C:/Users/Admin/Documents/ml-latest-small/movies.csv")

ratings <- read_csv("C:/Users/Admin/Documents/ml-latest-small/ratings.csv")

Bộ dữ liệu movies miêu tả về phim (tên phim + thể loại) và mã movieId tương ứng của phim. Còn bộ dữ liệu ratings là xếp hạng của người dùng đối với một bộ phim mà họ đã xem. Chúng ta có thể xem qua những dữ liệu này:

# Show some observations: 

library(knitr)

kable(movies %>% head(), caption = "Table 1: Movie data")
Table 1: Movie data
movieId title genres
1 Toy Story (1995) Adventure|Animation|Children|Comedy|Fantasy
2 Jumanji (1995) Adventure|Children|Fantasy
3 Grumpier Old Men (1995) Comedy|Romance
4 Waiting to Exhale (1995) Comedy|Drama|Romance
5 Father of the Bride Part II (1995) Comedy
6 Heat (1995) Action|Crime|Thriller
kable(ratings %>% head(), caption = "Table 2: Rating data")
Table 2: Rating data
userId movieId rating timestamp
1 1 4 964982703
1 3 4 964981247
1 6 4 964982224
1 47 5 964983815
1 50 5 964982931
1 70 3 964982400

Riêng cột biến timestamp là ở dạng Epoch time và chúng ta có thể chuyển về date time để thực hiện những phân tích xa hơn nếu cần:

# Convert to real date time:

library(lubridate)

ratings %>% 
  mutate(real_time = as_datetime(timestamp), date_ymd = date(real_time)) -> ratings

Cột biến genres mô tả thể loại của phim. Để thuận lợi cho các phân tích sau này thì no genres listed nên thay bằng Other và dấu - được thay bằng _ như sau:

# Relabel for some genres: 

movies %>% 
  mutate(genres = genres %>% 
           str_replace_all("\\(no genres listed\\)", "Other") %>% 
           str_replace_all("-", "_")) -> movies

Chúng ta kì vọng rằng số lượng các movieId ở hai bộ số liệu là trùng nhau nhưng thực tế không phải vậy:

ratings$movieId %>% n_distinct()
## [1] 9724
movies$movieId %>% n_distinct()
## [1] 9742

Do vậy chúng ta sẽ chỉ lấy ra những bộ phim mà thuộc về cả hai bộ dữ liệu ratings và movies:

# Common movieId: 

base::intersect(ratings$movieId %>% unique(), movies$movieId %>% unique()) -> common_movies

# Filter data by common_movies: 

movies %>% filter(movieId %in% common_movies) -> movies_common

ratings %>% filter(movieId %in% common_movies) -> ratings_common

# Combine data: 

full_join(movies_common, ratings_common, by = "movieId") -> movie_data

# Some observations: 

movie_data %>% head()
## # A tibble: 6 x 8
##   movieId title  genres   userId rating timestamp real_time           date_ymd  
##     <dbl> <chr>  <chr>     <dbl>  <dbl>     <dbl> <dttm>              <date>    
## 1       1 Toy S~ Adventu~      1    4      9.65e8 2000-07-30 18:45:03 2000-07-30
## 2       1 Toy S~ Adventu~      5    4      8.47e8 1996-11-08 06:36:02 1996-11-08
## 3       1 Toy S~ Adventu~      7    4.5    1.11e9 2005-01-25 06:52:26 2005-01-25
## 4       1 Toy S~ Adventu~     15    2.5    1.51e9 2017-11-13 12:59:30 2017-11-13
## 5       1 Toy S~ Adventu~     17    4.5    1.31e9 2011-05-18 05:28:03 2011-05-18
## 6       1 Toy S~ Adventu~     18    3.5    1.46e9 2016-02-11 16:56:56 2016-02-11

Như đã đề cập CFR là cách tiếp cận mà chỉ căn cứ hành vi của user chứ không sử dụng thông tin về content của phim (hay sách). Điều này có nghĩa là cột biến genres về thể loại phim (có thể xem như đây là một dạng content đơn giản) sẽ không sử dụng khi xây dựng CFR. Tuy nhiên chúng ta có thể sử dụng genres sau này. Vậy nên tiện thể chúng ta viết một hàm có tên convert_to_01_content để covert về ma trận nhị phân (binary matrix)

convert_to_01_content <- function(genres) {
  
  genres %>% 
    str_split(pattern = "\\|", simplify = TRUE) %>% 
    data.frame() -> df_ith
  
  names(df_ith) <- df_ith %>% slice(1) 
  
  return(df_ith %>% mutate(genres = genres))
}

Sử dụng hàm này cho cột biến genres đồng thời tạo binary matrix/data frame:

lapply(movie_data$genres, convert_to_01_content) -> df_genres

do.call("bind_rows", df_genres) -> df_genres

# Convert to binary matrix/data frame: 

df_genres %>% 
  select(-genres) %>% 
  mutate_all(function(x) {case_when(is.na(x) ~ 0, TRUE ~ 1)}) -> df_genres01

# Combine data sets: 

movie_data %>% bind_cols(df_genres01) -> movie_data_01 

# Show some observations: 

movie_data_01 %>% head()
## # A tibble: 6 x 28
##   movieId title genres userId rating timestamp real_time           date_ymd  
##     <dbl> <chr> <chr>   <dbl>  <dbl>     <dbl> <dttm>              <date>    
## 1       1 Toy ~ Adven~      1    4      9.65e8 2000-07-30 18:45:03 2000-07-30
## 2       1 Toy ~ Adven~      5    4      8.47e8 1996-11-08 06:36:02 1996-11-08
## 3       1 Toy ~ Adven~      7    4.5    1.11e9 2005-01-25 06:52:26 2005-01-25
## 4       1 Toy ~ Adven~     15    2.5    1.51e9 2017-11-13 12:59:30 2017-11-13
## 5       1 Toy ~ Adven~     17    4.5    1.31e9 2011-05-18 05:28:03 2011-05-18
## 6       1 Toy ~ Adven~     18    3.5    1.46e9 2016-02-11 16:56:56 2016-02-11
## # ... with 20 more variables: Adventure <dbl>, Animation <dbl>, Children <dbl>,
## #   Comedy <dbl>, Fantasy <dbl>, Romance <dbl>, Drama <dbl>, Action <dbl>,
## #   Crime <dbl>, Thriller <dbl>, Horror <dbl>, Mystery <dbl>, Sci_Fi <dbl>,
## #   War <dbl>, Musical <dbl>, Documentary <dbl>, IMAX <dbl>, Western <dbl>,
## #   Film_Noir <dbl>, Other <dbl>

Chúng ta có thể rút ra, ví dụ, một số insights về hành vi tiêu dùng/xem phim của các users:

# Most popular genres: 

df_genres01 %>% 
  gather(Genre, n) %>% 
  group_by(Genre) %>% 
  summarise(Freq = sum(n)) %>% 
  arrange(Freq) %>% 
  mutate(Genre = factor(Genre, levels = Genre)) %>% 
  ggplot(aes(Genre, Freq)) + 
  geom_col() + 
  coord_flip() + 
  labs(x = NULL, y = NULL, title = "Figure 1: Most Viewed Movies")

# Genre trend: 

movie_data_01 %>% 
  select(-c(1:7)) %>% 
  gather(genres, count, -date_ymd) %>% 
  group_by(date_ymd, genres) %>% 
  summarise(total = sum(count)) %>% 
  ggplot(aes(date_ymd, total, color = genres)) + 
  geom_line(show.legend = FALSE) + 
  facet_wrap(~ genres, scales = "free_x") + 
  labs(x = NULL, y = NULL, title = "Figure 2: Trend of Viewed Movies")

# Most watched movies (top 10): 

movie_data_01 %>% 
  group_by(title) %>% 
  count() %>% 
  arrange(-n) %>% 
  head(10) %>% 
  kable(caption = "Table 3: Top-10 Movies")
Table 3: Top-10 Movies
title n
Forrest Gump (1994) 329
Shawshank Redemption, The (1994) 317
Pulp Fiction (1994) 307
Silence of the Lambs, The (1991) 279
Matrix, The (1999) 278
Star Wars: Episode IV - A New Hope (1977) 251
Jurassic Park (1993) 238
Braveheart (1995) 237
Terminator 2: Judgment Day (1991) 224
Schindler’s List (1993) 220

Không nằm ngoài dự đoán, Shawshank Redemption (bộ phim ưa thích của người viết bài này) có số lượt xem xếp thứ hai chỉ sau phim dành giải Oscar năm 1994 Forrest Gump. Chúng ta tìm hiểu thêm về một số thông tin có thể là hữu ích thu được từ bộ dữ liệu. Ví dụ: tổng số lượt người xem phim là 100836 cho tất cả 9724 bộ phim, như vậy bình quân một bộ phim được xem chừng 10.37 lần nhưng phân bố rất lệch. Thực vậy, chỉ có 450 bộ phim có số lượt xem nhiều hơn 50:

movie_data_01 %>% 
  group_by(title) %>% 
  count() %>% 
  filter(n >= 50) %>% 
  arrange(-n) -> movies_over50_viewed


kable(movies_over50_viewed %>% head(), caption = "Table 4: Top 450 Movies")
Table 4: Top 450 Movies
title n
Forrest Gump (1994) 329
Shawshank Redemption, The (1994) 317
Pulp Fiction (1994) 307
Silence of the Lambs, The (1991) 279
Matrix, The (1999) 278
Star Wars: Episode IV - A New Hope (1977) 251

Figure 3 cho thấy rating = 4 là xếp hạng phổ biến nhất của người dùng dành cho các bộ phim:

movie_data_01 %>% 
  group_by(rating) %>% 
  count() %>% 
  ungroup() %>% 
  mutate(rating = as.factor(rating)) %>% 
  ggplot(aes(rating, n)) + 
  geom_col() + 
  labs(x = NULL, y = NULL, title = "Figure 3: Distribution of Rating")

Recommender based on Ratings: Item-based CFR

CFR có thể được xây dựng dựa vào xếp hạng (ratings) mà user bình bầu cho bộ phim họ đã xem. Nhưng trước hết chúng ta cần convert xếp hạng của những bộ phim bởi user (người xem) về ma trận thưa (sparse matrix):

# Convert to sparse matrix for ratings: 

ratings %>% 
  select(movieId, userId, rating) %>% 
  spread(value = rating, key = movieId) -> sparse_df

Vì có 610 users và 9724 bộ phim nên sparse matrix sẽ nên là một ma trận có 610 dòng và 9724 cột. Kết quả sau đây cho thấy sparse data frame dư ra một cột là userId (các cột biến khác chính là movieId - hay là các items):

head(sparse_df[1:6, 1:6])
## # A tibble: 6 x 6
##   userId   `1`   `2`   `3`   `4`   `5`
##    <dbl> <dbl> <dbl> <dbl> <dbl> <dbl>
## 1      1     4    NA     4    NA    NA
## 2      2    NA    NA    NA    NA    NA
## 3      3    NA    NA    NA    NA    NA
## 4      4    NA    NA    NA    NA    NA
## 5      5     4    NA    NA    NA    NA
## 6      6    NA     4     5     3     5

Do vậy chúng ta cần loại bỏ cột biến userId này trước khi convert về sparse matrix bằng hàm as() như sau:

# Remove userId and convert to matrix: 

sparse_df %>% 
  select(-userId) %>% 
  as.matrix() -> ratingmat

# Convert to realRatingMatrix class for modeling Recommendation System: 

library(recommenderlab)

as(ratingmat, "realRatingMatrix") -> ratingmat

CFR vận hành dựa trên thước đo gọi là sự tương tự (similarity) hay “khoảng cách” giữa các: (1) users, (2) hoặc items và được tính toán theo ba cách khác nhau: (1) cosine similarity, (2) Pearson similarity, và (3) Jaccard similarity. Thước đo này được tính toán bằng cách sử dụng hàm similarity() của thư viện recommenderlab như sau:

# Calculate cosine similarity for 10 observation by user: 

similarity_users <- similarity(ratingmat[1:10, ], method = "cosine", which = "users")

# Plot cosine similarity heatmap: 

image(as.matrix(similarity_users), main = "Figure 4: Similarity by User")

Hoặc them items:

# Calculate cosine similarity for 10 observation by item: 

similarity_items <- similarity(ratingmat[, 1:10], method = "cosine", which = "items")

image(as.matrix(similarity_items), main = "Figure 5: Similarity by Item")

Tương tự như vậy là heatmap cho rating mà ở đó mỗi một dòng là một user còn mỗi một cột là movie. Ví dụ, 20 users + 20 movies đầu tiên:

image(ratingmat[1:20, 1:20], main = "Figure 6: Heatmap of the first 20 rows and 20 columns")

Sử dụng 80% data để train CFR và phần dữ liệu còn lại để test CFR:

set.seed(1)
id <- sample(x = 1:nrow(ratingmat), size = 0.8*nrow(ratingmat), replace = FALSE)
data_train <- ratingmat[id, ]
data_test <- ratingmat[-id, ]

Huấn luyện CFR Engine:

# Define some parameters for CFR Engine: 

my_params <- list(k = 30, method = "Cosine")

# Train CFR recommender: 

cfr_recommender <- Recommender(data = data_train, 
                               method = "IBCF",
                               parameter = my_params)

Sử dụng CFR Engine đã có để đưa ra recommendations cho các users:

# Use CFR recommender for test data. 

n_recommended <- 3

items_predicted <- predict(object = cfr_recommender, 
                           newdata = data_test, 
                           n = n_recommended)

Chúng ta cũng có thể list danh sách 20 bộ phim được đề xuất nhiều nhất bởi CFR Engine (Nixon (1995) là bộ phim được khuyến nghị nhiều lần nhất bởi CFR Engine với 10 lần):

data.frame(movieId = unlist(items_predicted@items)) %>% 
  group_by(movieId) %>% 
  count() %>% 
  ungroup() %>% 
  top_n(n = 20, wt = n) %>% 
  arrange(n) %>% 
  inner_join(movies_common %>% select(1:2)) %>% 
  mutate(title = factor(title, levels = title)) -> top_20_recommended

top_20_recommended %>% 
  ggplot(aes(title, n)) + 
  geom_col() + 
  geom_text(aes(label = n), hjust = 1.3, color = "white") + 
  coord_flip() + 
  labs(x = NULL, y = NULL, title = "Figure 7: Top-recommended Movies by CFR")

Xem 3 bộ phim mà Engine khuyến nghị cho, ví dụ, user thứ nhất (trong test data):

movies_recommended_user1 <- items_predicted@items[[1]]

movies_common %>% 
  filter(movieId %in% movies_recommended_user1) %>% 
  select(movieId, title)
## # A tibble: 3 x 2
##   movieId title                                
##     <dbl> <chr>                                
## 1       9 Sudden Death (1995)                  
## 2      71 Fair Game (1995)                     
## 3     105 Bridges of Madison County, The (1995)

Final Notes

Post này trình tập trung vào việc thực hiện tiền xử lí số liệu - chuẩn bị số liệu cho đến việc xây dựng một hệ thống khuyến nghị bằng ngôn ngữ R mà cụ thể là Collaborative Filtering Recommender. Một số vấn đề quan trọng vẫn chưa được đề cập/giải quyết là:

  1. Đánh giá chất lượng của Recommender Engine. Ví dụ, sử dụng CFR Engine đã có chúng ta đưa ra khuyến cáo cụ thể ba bộ phim mà user thứ nhất nên xem. Nhưng làm thế nào để đánh giá khuyến nghị này có khớp với nhu cầu và sở thích của người này hay không?

  2. Một số cách tiếp cận khác cho xây dựng Recommender Engine cũng như tinh chỉnh tham số.

  3. Chất lượng của Engine còn phụ thuộc rất nhiều lựa chọn các observations phù hợp. Ví dụ: một mô hình dựa trên hành vi của user thì quan sát về user phải đủ nhiều để engine có thể “trích xuất” ra insights/pattern đại diện cho hành vi tiêu dùng (trong tình huống này là xem phim). CFR Engine được xây dựng trong post này sử dụng cả những user mà họ chỉ xem một vài phim. Những user này quá ít tương tác và do vậy hành vi của họ không rõ ràng (hoặc không có) vì thế sử dụng cả những user này khi xây dựng Recommender có thể không phù hợp.

  4. Nếu có sẵn dữ liệu về ratings (là tình huống của bộ dữ liệu sử dụng trong post này) thì việc xây dựng Recommender Engine là thuận tiện. Tuy nhiên trong thực tế thì không phải lúc nào cũng có dữ liệu về rating và xây dựng một Recommender Engine lúc này trở nên khó khăn hơn.

LS0tDQp0aXRsZTogJ1JlY29tbWVuZGF0aW9uIFN5c3RlbSAoUGFydCAxKScNCmF1dGhvcjogJ05ndXllbiBDaGkgRHVuZycNCnN1YnRpdGxlOiAiUiBNYWNoaW5lIExlYXJuaW5nIFNlcmllcyINCm91dHB1dDoNCiAgaHRtbF9kb2N1bWVudDogDQogICAgY29kZV9kb3dubG9hZDogdHJ1ZQ0KICAgICMgY29kZV9mb2xkaW5nOiBoaWRlDQogICAgaGlnaGxpZ2h0OiB6ZW5idXJuDQogICAgIyBudW1iZXJfc2VjdGlvbnM6IHllcw0KICAgIHRoZW1lOiAiZmxhdGx5Ig0KICAgIHRvYzogVFJVRQ0KICAgIHRvY19mbG9hdDogVFJVRQ0KLS0tDQoNCmBgYHtyIHNldHVwLGluY2x1ZGU9RkFMU0V9DQprbml0cjo6b3B0c19jaHVuayRzZXQoZWNobyA9IFRSVUUsIHdhcm5pbmcgPSBGQUxTRSwgbWVzc2FnZSA9IEZBTFNFLCBmaWcud2lkdGggPSAxMCwgZmlnLmhlaWdodCA9IDYpDQpgYGANCg0KDQojIEludHJvZHVjdGlvbiB0byBDb2xsYWJvcmF0aXZlIEZpbHRlcmluZyBSZWNvbW1lbmRlcg0KDQpI4buHIHRo4buRbmcga2h1eeG6v24gbmdo4buLIChSZWNvbW1lbmRhdGlvbiBTeXN0ZW0pIGzDoCBt4buZdCBFbmdpbmUgxJHGsOG7o2Mgc+G7rSBk4bulbmcgcGjhu5UgYmnhur9uIOG7nyBuaGnhu4F1IGPDtG5nIHRpIHRoxrDGoW5nIG3huqFpIMSRaeG7h24gdOG7rS4gQ29sbGFib3JhdGl2ZSBGaWx0ZXJpbmcgUmVjb21tZW5kZXIgKENGUikgbMOgIG3hu5l0IGPDoWNoIHRp4bq/cCBj4bqtbiAobcO0IGjDrG5oKSBk4buxYSB0csOqbiBk4buvIGxp4buHdSBs4buLY2ggc+G7rSB24buBIGjDoG5oIHZpIG11YSBz4bqvbSBj4bunYSBraMOhY2ggaMOgbmcgduG7m2kgw70gdMaw4bufbmcgcuG6sW5nIG7hur91LCB2w60gZOG7pSwgaGFpIGtow6FjaCBow6BuZyBjw7luZyBtdWEgbeG7mXQgY3Xhu5FuIHPDoWNoIChob+G6t2MgeGVtIGPDuW5nIG3hu5l0IGLhu5kgcGhpbSkgdGjDrCBy4bqldCBjw7MgdGjhu4MgaOG7jSBz4bq9IHTDoWkgZGnhu4VuIHBhdHRlcm4gxJHDsyB0cm9uZyB0xrDGoW5nIGxhaS4gSG/hurdjIGhhaSBraMOhY2ggaMOgbmcgQSB2w6AgQiDEkcaw4bujYyBjaG8gbMOgIGPDsyBow6BuaCB2aSBtdWEgc+G6r20gdMawxqFuZyB04buxIG5oYXUgdGjDrCBu4bq/dSBraMOhY2ggaMOgbmcgQSDEkcOjIHhlbSBt4buZdCBi4buZIHBoaW0gWCBuw6BvIMSRw7MgbmjGsG5nIEIgdGjDrCBs4bqhaSBjaMawYSB4ZW0gYuG7mSBwaGltIG7DoHkgdGjDrCBu4bq/dSBo4buHIHRo4buRbmcgZ+G7o2kgw70gcuG6sW5nIG7Dqm4gZ2nhu5tpIHRoaeG7h3UgcGhpbSBYIG7DoHkgY2hvIGtow6FjaCBow6BuZyBCLiBDw6FjaCB0aeG6v3AgY+G6rW4gbsOgeSByw7UgcsOgbmcgY2jhu4kgZOG7sWEgdHLDqm4gc+G7nyB0aMOtY2ggY+G7p2Ega2jDoWNoIGjDoG5nICh1c2VyIHByZWZlcmVuY2VzKSBjaOG7qSBraMO0bmcgZOG7sWEgdHLDqm4gbmjhu69uZyDEkeG6t2MgxJFp4buDbSBoYXkgbuG7mWkgZHVuZyAoY29udGVudHMpIGPhu6dhIHPDoWNoL3BoaW0gxJHhu4MgxJHGsGEgcmEga2h1eeG6v24gbmdo4buLIGNobyBuZ8aw4budaSBkw7luZy9raMOhY2ggaMOgbmcgdGnhu4FtIG7Eg25nLiANCg0KDQpCw6BpIHZp4bq/dCBuw6B5IG1pbmggaOG7jWEgdmnhu4djIHjDonkgZOG7sW5nIENGUiBi4bqxbmcgbmfDtG4gbmfhu68gUiB24bubaSBi4buZIGThu68gbGnhu4d1IHThu6sgTW92aWVMZW5zIChwaGnDqm4gYuG6o24gc21hbGwpIGJhbyBn4buTbSAxMDUzMzkgcmF0aW5ncyBj4bunYSAxMDMyOSBi4buZIHBoaW0gdOG7qyA2NjggdXNlciB0cm9uZyBraG/huqNuZyB0aOG7nWkgZ2lhbiB04burIEFwcmlsIDAzLCAxOTk2IMSR4bq/biBKYW51YXJ5IDA5LCAyMDE2LiBC4buZIGThu68gbGnhu4d1IG7DoHkgY8OzIHRo4buDIFtkb3dubG9hZCDhu58gxJHDonldKGh0dHA6Ly9ncm91cGxlbnMub3JnL2RhdGFzZXRzL21vdmllbGVucy9sYXRlc3QpLiDEkOG7i25oIGjGsOG7m25nIHRo4buxYyBow6BuaCBuw6puIGPDoWMgbMOtIHRodXnhur90IHbDoCBjxqEgc+G7nyB0b8OhbiBo4buNYyB24buBIFJlY29tbWVuZGF0aW9uIFN5c3RlbSBz4bq9IGtow7RuZyDEkcaw4bujYyB0csOsbmggYsOgeSDhu58gxJHDonkuIELhuqFuIMSR4buNYyDEkcaw4bujYyBnaeG6oyDEkeG7i25oIGzDoCDEkcOjIGPDsyBuaOG7r25nIGhp4buDdSBiaeG6v3QgbmjhuqV0IMSR4buLbmggduG7gSBSZWNvbW1lbmRhdGlvbiBTeXN0ZW0uIA0KDQojIERhdGEgcHJlLXByb2Nlc3NpbmcgYW5kIEV4cGxvcmF0b3J5IERhdGEgQW5hbHlzaXMgDQoNClRp4buBbiB44butIGzDrSBz4buRIGxp4buHdSAoRGF0YSBwcmUtcHJvY2Vzc2luZykgdsOgIHBow6JuIHTDrWNoIGtow6FtIHBow6EgZOG7ryBsaeG7h3UgRURBIChFeHBsb3JhdG9yeSBEYXRhIEFuYWx5c2lzKSBsw6AgYsaw4bubYyBsdcO0biBwaOG6o2kgxJHGsOG7o2MgdGjhu7FjIGhp4buHbiDEkeG6p3UgdGnDqm4gY+G7p2EgbeG7mXQgZGF0YSBwcm9qZWN0LiBUcsaw4bubYyBo4bq/dCDEkeG7jWMgaGFpIGZpbGVzIGThu68gbGnhu4d1ICoqbW92aWVzLmNzdioqIHbDoCAqKnJhdGluZ3MuY3N2Kio6IA0KDQpgYGB7cn0NCg0KIyBDbGVhciB3b3Jrc3BhY2U6IA0KDQpybShsaXN0ID0gbHMoKSkNCg0KIyBMb2FkIGRhdGE6IA0KDQpsaWJyYXJ5KHRpZHl2ZXJzZSkNCg0KbW92aWVzIDwtIHJlYWRfY3N2KCJDOi9Vc2Vycy9BZG1pbi9Eb2N1bWVudHMvbWwtbGF0ZXN0LXNtYWxsL21vdmllcy5jc3YiKQ0KDQpyYXRpbmdzIDwtIHJlYWRfY3N2KCJDOi9Vc2Vycy9BZG1pbi9Eb2N1bWVudHMvbWwtbGF0ZXN0LXNtYWxsL3JhdGluZ3MuY3N2IikNCg0KYGBgDQoNCkLhu5kgZOG7ryBsaeG7h3UgbW92aWVzIG1pw6p1IHThuqMgduG7gSBwaGltICh0w6puIHBoaW0gKyB0aOG7gyBsb+G6oWkpIHbDoCBtw6MgbW92aWVJZCB0xrDGoW5nIOG7qW5nIGPhu6dhIHBoaW0uIEPDsm4gYuG7mSBk4buvIGxp4buHdSByYXRpbmdzIGzDoCB44bq/cCBo4bqhbmcgY+G7p2EgbmfGsOG7nWkgZMO5bmcgxJHhu5FpIHbhu5tpIG3hu5l0IGLhu5kgcGhpbSBtw6AgaOG7jSDEkcOjIHhlbS4gQ2jDum5nIHRhIGPDsyB0aOG7gyB4ZW0gcXVhIG5o4buvbmcgZOG7ryBsaeG7h3UgbsOgeTogDQoNCmBgYHtyfQ0KDQojIFNob3cgc29tZSBvYnNlcnZhdGlvbnM6IA0KDQpsaWJyYXJ5KGtuaXRyKQ0KDQprYWJsZShtb3ZpZXMgJT4lIGhlYWQoKSwgY2FwdGlvbiA9ICJUYWJsZSAxOiBNb3ZpZSBkYXRhIikNCg0Ka2FibGUocmF0aW5ncyAlPiUgaGVhZCgpLCBjYXB0aW9uID0gIlRhYmxlIDI6IFJhdGluZyBkYXRhIikNCmBgYA0KDQpSacOqbmcgY+G7mXQgYmnhur9uIHRpbWVzdGFtcCBsw6Ag4bufIGThuqFuZyBFcG9jaCB0aW1lIHbDoCBjaMO6bmcgdGEgY8OzIHRo4buDIGNodXnhu4NuIHbhu4EgZGF0ZSB0aW1lIMSR4buDIHRo4buxYyBoaeG7h24gbmjhu69uZyBwaMOibiB0w61jaCB4YSBoxqFuIG7hur91IGPhuqduOiANCg0KDQpgYGB7cn0NCiMgQ29udmVydCB0byByZWFsIGRhdGUgdGltZToNCg0KbGlicmFyeShsdWJyaWRhdGUpDQoNCnJhdGluZ3MgJT4lIA0KICBtdXRhdGUocmVhbF90aW1lID0gYXNfZGF0ZXRpbWUodGltZXN0YW1wKSwgZGF0ZV95bWQgPSBkYXRlKHJlYWxfdGltZSkpIC0+IHJhdGluZ3MNCmBgYA0KDQpD4buZdCBiaeG6v24gZ2VucmVzIG3DtCB04bqjIHRo4buDIGxv4bqhaSBj4bunYSBwaGltLiDEkOG7gyB0aHXhuq1uIGzhu6NpIGNobyBjw6FjIHBow6JuIHTDrWNoIHNhdSBuw6B5IHRow6wgKm5vIGdlbnJlcyBsaXN0ZWQqIG7Dqm4gdGhheSBi4bqxbmcgT3RoZXIgdsOgIGThuqV1IC0gxJHGsOG7o2MgdGhheSBi4bqxbmcgXyBuaMawIHNhdTogDQoNCmBgYHtyfQ0KIyBSZWxhYmVsIGZvciBzb21lIGdlbnJlczogDQoNCm1vdmllcyAlPiUgDQogIG11dGF0ZShnZW5yZXMgPSBnZW5yZXMgJT4lIA0KICAgICAgICAgICBzdHJfcmVwbGFjZV9hbGwoIlxcKG5vIGdlbnJlcyBsaXN0ZWRcXCkiLCAiT3RoZXIiKSAlPiUgDQogICAgICAgICAgIHN0cl9yZXBsYWNlX2FsbCgiLSIsICJfIikpIC0+IG1vdmllcw0KYGBgDQoNCkNow7puZyB0YSBrw6wgduG7jW5nIHLhurFuZyBz4buRIGzGsOG7o25nIGPDoWMgbW92aWVJZCDhu58gaGFpIGLhu5kgc+G7kSBsaeG7h3UgbMOgIHRyw7luZyBuaGF1IG5oxrBuZyB0aOG7sWMgdOG6vyBraMO0bmcgcGjhuqNpIHbhuq15OiANCg0KYGBge3J9DQoNCnJhdGluZ3MkbW92aWVJZCAlPiUgbl9kaXN0aW5jdCgpDQoNCm1vdmllcyRtb3ZpZUlkICU+JSBuX2Rpc3RpbmN0KCkNCmBgYA0KDQpEbyB24bqteSBjaMO6bmcgdGEgc+G6vSBjaOG7iSBs4bqleSByYSBuaOG7r25nIGLhu5kgcGhpbSBtw6AgdGh14buZYyB24buBIGPhuqMgaGFpIGLhu5kgZOG7ryBsaeG7h3UgcmF0aW5ncyB2w6AgbW92aWVzOiANCg0KYGBge3J9DQoNCiMgQ29tbW9uIG1vdmllSWQ6IA0KDQpiYXNlOjppbnRlcnNlY3QocmF0aW5ncyRtb3ZpZUlkICU+JSB1bmlxdWUoKSwgbW92aWVzJG1vdmllSWQgJT4lIHVuaXF1ZSgpKSAtPiBjb21tb25fbW92aWVzDQoNCiMgRmlsdGVyIGRhdGEgYnkgY29tbW9uX21vdmllczogDQoNCm1vdmllcyAlPiUgZmlsdGVyKG1vdmllSWQgJWluJSBjb21tb25fbW92aWVzKSAtPiBtb3ZpZXNfY29tbW9uDQoNCnJhdGluZ3MgJT4lIGZpbHRlcihtb3ZpZUlkICVpbiUgY29tbW9uX21vdmllcykgLT4gcmF0aW5nc19jb21tb24NCg0KIyBDb21iaW5lIGRhdGE6IA0KDQpmdWxsX2pvaW4obW92aWVzX2NvbW1vbiwgcmF0aW5nc19jb21tb24sIGJ5ID0gIm1vdmllSWQiKSAtPiBtb3ZpZV9kYXRhDQoNCiMgU29tZSBvYnNlcnZhdGlvbnM6IA0KDQptb3ZpZV9kYXRhICU+JSBoZWFkKCkNCg0KYGBgDQoNCk5oxrAgxJHDoyDEkeG7gSBj4bqtcCBDRlIgbMOgIGPDoWNoIHRp4bq/cCBj4bqtbiBtw6AgY2jhu4kgY8SDbiBj4bupIGjDoG5oIHZpIGPhu6dhIHVzZXIgY2jhu6kga2jDtG5nIHPhu60gZOG7pW5nIHRow7RuZyB0aW4gduG7gSBjb250ZW50IGPhu6dhIHBoaW0gKGhheSBzw6FjaCkuIMSQaeG7gXUgbsOgeSBjw7MgbmdoxKlhIGzDoCBj4buZdCBiaeG6v24gZ2VucmVzIHbhu4EgdGjhu4MgbG/huqFpIHBoaW0gKGPDsyB0aOG7gyB4ZW0gbmjGsCDEkcOieSBsw6AgbeG7mXQgZOG6oW5nIGNvbnRlbnQgxJHGoW4gZ2nhuqNuKSBz4bq9IGtow7RuZyBz4butIGThu6VuZyBraGkgeMOieSBk4buxbmcgQ0ZSLiBUdXkgbmhpw6puIGNow7puZyB0YSBjw7MgdGjhu4Mgc+G7rSBk4bulbmcgZ2VucmVzIHNhdSBuw6B5LiBW4bqteSBuw6puIHRp4buHbiB0aOG7gyBjaMO6bmcgdGEgdmnhur90IG3hu5l0IGjDoG0gY8OzIHTDqm4gKipjb252ZXJ0X3RvXzAxX2NvbnRlbnQqKiDEkeG7gyBjb3ZlcnQgduG7gSBtYSB0cuG6rW4gbmjhu4sgcGjDom4gKGJpbmFyeSBtYXRyaXgpDQoNCmBgYHtyfQ0KY29udmVydF90b18wMV9jb250ZW50IDwtIGZ1bmN0aW9uKGdlbnJlcykgew0KICANCiAgZ2VucmVzICU+JSANCiAgICBzdHJfc3BsaXQocGF0dGVybiA9ICJcXHwiLCBzaW1wbGlmeSA9IFRSVUUpICU+JSANCiAgICBkYXRhLmZyYW1lKCkgLT4gZGZfaXRoDQogIA0KICBuYW1lcyhkZl9pdGgpIDwtIGRmX2l0aCAlPiUgc2xpY2UoMSkgDQogIA0KICByZXR1cm4oZGZfaXRoICU+JSBtdXRhdGUoZ2VucmVzID0gZ2VucmVzKSkNCn0NCg0KYGBgDQoNClPhu60gZOG7pW5nIGjDoG0gbsOgeSBjaG8gY+G7mXQgYmnhur9uIGdlbnJlcyDEkeG7k25nIHRo4budaSB04bqhbyBiaW5hcnkgbWF0cml4L2RhdGEgZnJhbWU6IA0KDQpgYGB7cn0NCg0KbGFwcGx5KG1vdmllX2RhdGEkZ2VucmVzLCBjb252ZXJ0X3RvXzAxX2NvbnRlbnQpIC0+IGRmX2dlbnJlcw0KDQpkby5jYWxsKCJiaW5kX3Jvd3MiLCBkZl9nZW5yZXMpIC0+IGRmX2dlbnJlcw0KDQojIENvbnZlcnQgdG8gYmluYXJ5IG1hdHJpeC9kYXRhIGZyYW1lOiANCg0KZGZfZ2VucmVzICU+JSANCiAgc2VsZWN0KC1nZW5yZXMpICU+JSANCiAgbXV0YXRlX2FsbChmdW5jdGlvbih4KSB7Y2FzZV93aGVuKGlzLm5hKHgpIH4gMCwgVFJVRSB+IDEpfSkgLT4gZGZfZ2VucmVzMDENCg0KIyBDb21iaW5lIGRhdGEgc2V0czogDQoNCm1vdmllX2RhdGEgJT4lIGJpbmRfY29scyhkZl9nZW5yZXMwMSkgLT4gbW92aWVfZGF0YV8wMSANCg0KIyBTaG93IHNvbWUgb2JzZXJ2YXRpb25zOiANCg0KbW92aWVfZGF0YV8wMSAlPiUgaGVhZCgpDQoNCmBgYA0KDQpDaMO6bmcgdGEgY8OzIHRo4buDIHLDunQgcmEsIHbDrSBk4bulLCBt4buZdCBz4buRIGluc2lnaHRzIHbhu4EgaMOgbmggdmkgdGnDqnUgZMO5bmcveGVtIHBoaW0gY+G7p2EgY8OhYyB1c2VyczogDQoNCmBgYHtyfQ0KDQojIE1vc3QgcG9wdWxhciBnZW5yZXM6IA0KDQpkZl9nZW5yZXMwMSAlPiUgDQogIGdhdGhlcihHZW5yZSwgbikgJT4lIA0KICBncm91cF9ieShHZW5yZSkgJT4lIA0KICBzdW1tYXJpc2UoRnJlcSA9IHN1bShuKSkgJT4lIA0KICBhcnJhbmdlKEZyZXEpICU+JSANCiAgbXV0YXRlKEdlbnJlID0gZmFjdG9yKEdlbnJlLCBsZXZlbHMgPSBHZW5yZSkpICU+JSANCiAgZ2dwbG90KGFlcyhHZW5yZSwgRnJlcSkpICsgDQogIGdlb21fY29sKCkgKyANCiAgY29vcmRfZmxpcCgpICsgDQogIGxhYnMoeCA9IE5VTEwsIHkgPSBOVUxMLCB0aXRsZSA9ICJGaWd1cmUgMTogTW9zdCBWaWV3ZWQgTW92aWVzIikNCg0KIyBHZW5yZSB0cmVuZDogDQoNCm1vdmllX2RhdGFfMDEgJT4lIA0KICBzZWxlY3QoLWMoMTo3KSkgJT4lIA0KICBnYXRoZXIoZ2VucmVzLCBjb3VudCwgLWRhdGVfeW1kKSAlPiUgDQogIGdyb3VwX2J5KGRhdGVfeW1kLCBnZW5yZXMpICU+JSANCiAgc3VtbWFyaXNlKHRvdGFsID0gc3VtKGNvdW50KSkgJT4lIA0KICBnZ3Bsb3QoYWVzKGRhdGVfeW1kLCB0b3RhbCwgY29sb3IgPSBnZW5yZXMpKSArIA0KICBnZW9tX2xpbmUoc2hvdy5sZWdlbmQgPSBGQUxTRSkgKyANCiAgZmFjZXRfd3JhcCh+IGdlbnJlcywgc2NhbGVzID0gImZyZWVfeCIpICsgDQogIGxhYnMoeCA9IE5VTEwsIHkgPSBOVUxMLCB0aXRsZSA9ICJGaWd1cmUgMjogVHJlbmQgb2YgVmlld2VkIE1vdmllcyIpDQoNCg0KIyBNb3N0IHdhdGNoZWQgbW92aWVzICh0b3AgMTApOiANCg0KbW92aWVfZGF0YV8wMSAlPiUgDQogIGdyb3VwX2J5KHRpdGxlKSAlPiUgDQogIGNvdW50KCkgJT4lIA0KICBhcnJhbmdlKC1uKSAlPiUgDQogIGhlYWQoMTApICU+JSANCiAga2FibGUoY2FwdGlvbiA9ICJUYWJsZSAzOiBUb3AtMTAgTW92aWVzIikNCmBgYA0KDQpLaMO0bmcgbuG6sW0gbmdvw6BpIGThu7EgxJFvw6FuLCBTaGF3c2hhbmsgUmVkZW1wdGlvbiAoYuG7mSBwaGltIMawYSB0aMOtY2ggY+G7p2EgbmfGsOG7nWkgdmnhur90IGLDoGkgbsOgeSkgY8OzIHPhu5EgbMaw4bujdCB4ZW0geOG6v3AgdGjhu6kgaGFpIGNo4buJIHNhdSBwaGltIGTDoG5oIGdp4bqjaSBPc2NhciBuxINtIDE5OTQgRm9ycmVzdCBHdW1wLiBDaMO6bmcgdGEgdMOsbSBoaeG7g3UgdGjDqm0gduG7gSBt4buZdCBz4buRIHRow7RuZyB0aW4gY8OzIHRo4buDIGzDoCBo4buvdSDDrWNoIHRodSDEkcaw4bujYyB04burIGLhu5kgZOG7ryBsaeG7h3UuIFbDrSBk4bulOiB04buVbmcgc+G7kSBsxrDhu6N0IG5nxrDhu51pIHhlbSBwaGltIGzDoCBgciBucm93KG1vdmllX2RhdGFfMDEpYCBjaG8gdOG6pXQgY+G6oyBgciBtb3ZpZV9kYXRhXzAxJG1vdmllSWQgJT4lIG5fZGlzdGluY3QoKWAgYuG7mSBwaGltLCBuaMawIHbhuq15IGLDrG5oIHF1w6JuIG3hu5l0IGLhu5kgcGhpbSDEkcaw4bujYyB4ZW0gY2jhu6tuZyAxMC4zNyBs4bqnbiBuaMawbmcgcGjDom4gYuG7kSBy4bqldCBs4buHY2guIFRo4buxYyB24bqteSwgY2jhu4kgY8OzIDQ1MCBi4buZIHBoaW0gY8OzIHPhu5EgbMaw4bujdCB4ZW0gbmhp4buBdSBoxqFuIDUwOiANCg0KYGBge3J9DQoNCm1vdmllX2RhdGFfMDEgJT4lIA0KICBncm91cF9ieSh0aXRsZSkgJT4lIA0KICBjb3VudCgpICU+JSANCiAgZmlsdGVyKG4gPj0gNTApICU+JSANCiAgYXJyYW5nZSgtbikgLT4gbW92aWVzX292ZXI1MF92aWV3ZWQNCg0KDQprYWJsZShtb3ZpZXNfb3ZlcjUwX3ZpZXdlZCAlPiUgaGVhZCgpLCBjYXB0aW9uID0gIlRhYmxlIDQ6IFRvcCA0NTAgTW92aWVzIikNCg0KYGBgDQoNCkZpZ3VyZSAzIGNobyB0aOG6pXkgcmF0aW5nID0gNCBsw6AgeOG6v3AgaOG6oW5nIHBo4buVIGJp4bq/biBuaOG6pXQgY+G7p2EgbmfGsOG7nWkgZMO5bmcgZMOgbmggY2hvIGPDoWMgYuG7mSBwaGltOiANCg0KYGBge3J9DQptb3ZpZV9kYXRhXzAxICU+JSANCiAgZ3JvdXBfYnkocmF0aW5nKSAlPiUgDQogIGNvdW50KCkgJT4lIA0KICB1bmdyb3VwKCkgJT4lIA0KICBtdXRhdGUocmF0aW5nID0gYXMuZmFjdG9yKHJhdGluZykpICU+JSANCiAgZ2dwbG90KGFlcyhyYXRpbmcsIG4pKSArIA0KICBnZW9tX2NvbCgpICsgDQogIGxhYnMoeCA9IE5VTEwsIHkgPSBOVUxMLCB0aXRsZSA9ICJGaWd1cmUgMzogRGlzdHJpYnV0aW9uIG9mIFJhdGluZyIpDQpgYGANCg0KDQojIFJlY29tbWVuZGVyIGJhc2VkIG9uIFJhdGluZ3M6IEl0ZW0tYmFzZWQgQ0ZSDQoNCkNGUiBjw7MgdGjhu4MgxJHGsOG7o2MgeMOieSBk4buxbmcgZOG7sWEgdsOgbyB44bq/cCBo4bqhbmcgKHJhdGluZ3MpIG3DoCB1c2VyIGLDrG5oIGLhuqd1IGNobyBi4buZIHBoaW0gaOG7jSDEkcOjIHhlbS4gTmjGsG5nIHRyxrDhu5tjIGjhur90IGNow7puZyB0YSBj4bqnbiBjb252ZXJ0IHjhur9wIGjhuqFuZyBj4bunYSBuaOG7r25nIGLhu5kgcGhpbSBi4bufaSB1c2VyIChuZ8aw4budaSB4ZW0pIHbhu4EgbWEgdHLhuq1uIHRoxrBhIChzcGFyc2UgbWF0cml4KTogDQoNCg0KYGBge3J9DQojIENvbnZlcnQgdG8gc3BhcnNlIG1hdHJpeCBmb3IgcmF0aW5nczogDQoNCnJhdGluZ3MgJT4lIA0KICBzZWxlY3QobW92aWVJZCwgdXNlcklkLCByYXRpbmcpICU+JSANCiAgc3ByZWFkKHZhbHVlID0gcmF0aW5nLCBrZXkgPSBtb3ZpZUlkKSAtPiBzcGFyc2VfZGYNCg0KYGBgDQoNClbDrCBjw7MgNjEwIHVzZXJzIHbDoCA5NzI0IGLhu5kgcGhpbSBuw6puIHNwYXJzZSBtYXRyaXggc+G6vSBuw6puIGzDoCBt4buZdCBtYSB0cuG6rW4gY8OzIDYxMCBkw7JuZyB2w6AgOTcyNCBj4buZdC4gS+G6v3QgcXXhuqMgc2F1IMSRw6J5IGNobyB0aOG6pXkgc3BhcnNlIGRhdGEgZnJhbWUgZMawIHJhIG3hu5l0IGPhu5l0IGzDoCB1c2VySWQgKGPDoWMgY+G7mXQgYmnhur9uIGtow6FjIGNow61uaCBsw6AgbW92aWVJZCAtIGhheSBsw6AgY8OhYyBpdGVtcyk6IA0KDQoNCmBgYHtyfQ0KaGVhZChzcGFyc2VfZGZbMTo2LCAxOjZdKQ0KYGBgDQoNCg0KRG8gduG6rXkgY2jDum5nIHRhIGPhuqduIGxv4bqhaSBi4buPIGPhu5l0IGJp4bq/biB1c2VySWQgbsOgeSB0csaw4bubYyBraGkgY29udmVydCB24buBIHNwYXJzZSBtYXRyaXggYuG6sW5nIGjDoG0gYGFzKClgIG5oxrAgc2F1OiANCg0KDQpgYGB7cn0NCg0KIyBSZW1vdmUgdXNlcklkIGFuZCBjb252ZXJ0IHRvIG1hdHJpeDogDQoNCnNwYXJzZV9kZiAlPiUgDQogIHNlbGVjdCgtdXNlcklkKSAlPiUgDQogIGFzLm1hdHJpeCgpIC0+IHJhdGluZ21hdA0KDQojIENvbnZlcnQgdG8gcmVhbFJhdGluZ01hdHJpeCBjbGFzcyBmb3IgbW9kZWxpbmcgUmVjb21tZW5kYXRpb24gU3lzdGVtOiANCg0KbGlicmFyeShyZWNvbW1lbmRlcmxhYikNCg0KYXMocmF0aW5nbWF0LCAicmVhbFJhdGluZ01hdHJpeCIpIC0+IHJhdGluZ21hdA0KDQpgYGANCg0KDQpDRlIgduG6rW4gaMOgbmggZOG7sWEgdHLDqm4gdGjGsOG7m2MgxJFvIGfhu41pIGzDoCBz4buxIHTGsMahbmcgdOG7sSAoc2ltaWxhcml0eSkgaGF5ICJraG/huqNuZyBjw6FjaCIgZ2nhu69hIGPDoWM6ICgxKSB1c2VycywgKDIpIGhv4bq3YyBpdGVtcyB2w6AgxJHGsOG7o2MgdMOtbmggdG/DoW4gdGhlbyBiYSBjw6FjaCBraMOhYyBuaGF1OiAoMSkgY29zaW5lIHNpbWlsYXJpdHksICgyKSBQZWFyc29uIHNpbWlsYXJpdHksIHbDoCAoMykgSmFjY2FyZCBzaW1pbGFyaXR5LiBUaMaw4bubYyDEkW8gbsOgeSDEkcaw4bujYyB0w61uaCB0b8OhbiBi4bqxbmcgY8OhY2ggc+G7rSBk4bulbmcgaMOgbSBgc2ltaWxhcml0eSgpYCBj4bunYSB0aMawIHZp4buHbiByZWNvbW1lbmRlcmxhYiBuaMawIHNhdTogDQoNCmBgYHtyfQ0KDQojIENhbGN1bGF0ZSBjb3NpbmUgc2ltaWxhcml0eSBmb3IgMTAgb2JzZXJ2YXRpb24gYnkgdXNlcjogDQoNCnNpbWlsYXJpdHlfdXNlcnMgPC0gc2ltaWxhcml0eShyYXRpbmdtYXRbMToxMCwgXSwgbWV0aG9kID0gImNvc2luZSIsIHdoaWNoID0gInVzZXJzIikNCg0KIyBQbG90IGNvc2luZSBzaW1pbGFyaXR5IGhlYXRtYXA6IA0KDQppbWFnZShhcy5tYXRyaXgoc2ltaWxhcml0eV91c2VycyksIG1haW4gPSAiRmlndXJlIDQ6IFNpbWlsYXJpdHkgYnkgVXNlciIpDQpgYGANCg0KSG/hurdjIHRoZW0gaXRlbXM6IA0KDQpgYGB7cn0NCiMgQ2FsY3VsYXRlIGNvc2luZSBzaW1pbGFyaXR5IGZvciAxMCBvYnNlcnZhdGlvbiBieSBpdGVtOiANCg0Kc2ltaWxhcml0eV9pdGVtcyA8LSBzaW1pbGFyaXR5KHJhdGluZ21hdFssIDE6MTBdLCBtZXRob2QgPSAiY29zaW5lIiwgd2hpY2ggPSAiaXRlbXMiKQ0KDQppbWFnZShhcy5tYXRyaXgoc2ltaWxhcml0eV9pdGVtcyksIG1haW4gPSAiRmlndXJlIDU6IFNpbWlsYXJpdHkgYnkgSXRlbSIpDQpgYGANCg0KVMawxqFuZyB04buxIG5oxrAgduG6rXkgbMOgIGhlYXRtYXAgY2hvIHJhdGluZyBtw6Ag4bufIMSRw7MgbeG7l2kgbeG7mXQgZMOybmcgbMOgIG3hu5l0IHVzZXIgY8OybiBt4buXaSBt4buZdCBj4buZdCBsw6AgbW92aWUuIFbDrSBk4bulLCAyMCB1c2VycyArIDIwIG1vdmllcyDEkeG6p3UgdGnDqm46IA0KDQpgYGB7cn0NCmltYWdlKHJhdGluZ21hdFsxOjIwLCAxOjIwXSwgbWFpbiA9ICJGaWd1cmUgNjogSGVhdG1hcCBvZiB0aGUgZmlyc3QgMjAgcm93cyBhbmQgMjAgY29sdW1ucyIpDQpgYGANCg0KDQpT4butIGThu6VuZyA4MCUgZGF0YSDEkeG7gyB0cmFpbiBDRlIgdsOgIHBo4bqnbiBk4buvIGxp4buHdSBjw7JuIGzhuqFpIMSR4buDIHRlc3QgQ0ZSOiANCg0KYGBge3J9DQpzZXQuc2VlZCgxKQ0KaWQgPC0gc2FtcGxlKHggPSAxOm5yb3cocmF0aW5nbWF0KSwgc2l6ZSA9IDAuOCpucm93KHJhdGluZ21hdCksIHJlcGxhY2UgPSBGQUxTRSkNCmRhdGFfdHJhaW4gPC0gcmF0aW5nbWF0W2lkLCBdDQpkYXRhX3Rlc3QgPC0gcmF0aW5nbWF0Wy1pZCwgXQ0KYGBgDQoNCkh14bqlbiBsdXnhu4duIENGUiBFbmdpbmU6IA0KDQpgYGB7cn0NCg0KIyBEZWZpbmUgc29tZSBwYXJhbWV0ZXJzIGZvciBDRlIgRW5naW5lOiANCg0KbXlfcGFyYW1zIDwtIGxpc3QoayA9IDMwLCBtZXRob2QgPSAiQ29zaW5lIikNCg0KIyBUcmFpbiBDRlIgcmVjb21tZW5kZXI6IA0KDQpjZnJfcmVjb21tZW5kZXIgPC0gUmVjb21tZW5kZXIoZGF0YSA9IGRhdGFfdHJhaW4sIA0KICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIG1ldGhvZCA9ICJJQkNGIiwNCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICBwYXJhbWV0ZXIgPSBteV9wYXJhbXMpDQoNCmBgYA0KDQpT4butIGThu6VuZyBDRlIgRW5naW5lIMSRw6MgY8OzIMSR4buDIMSRxrBhIHJhIHJlY29tbWVuZGF0aW9ucyBjaG8gY8OhYyB1c2VyczogDQoNCmBgYHtyfQ0KIyBVc2UgQ0ZSIHJlY29tbWVuZGVyIGZvciB0ZXN0IGRhdGEuIA0KDQpuX3JlY29tbWVuZGVkIDwtIDMNCg0KaXRlbXNfcHJlZGljdGVkIDwtIHByZWRpY3Qob2JqZWN0ID0gY2ZyX3JlY29tbWVuZGVyLCANCiAgICAgICAgICAgICAgICAgICAgICAgICAgIG5ld2RhdGEgPSBkYXRhX3Rlc3QsIA0KICAgICAgICAgICAgICAgICAgICAgICAgICAgbiA9IG5fcmVjb21tZW5kZWQpDQpgYGANCg0KQ2jDum5nIHRhIGPFqW5nIGPDsyB0aOG7gyBsaXN0IGRhbmggc8OhY2ggMjAgYuG7mSBwaGltIMSRxrDhu6NjIMSR4buBIHh14bqldCBuaGnhu4F1IG5o4bqldCBi4bufaSBDRlIgRW5naW5lIChOaXhvbiAoMTk5NSkgbMOgIGLhu5kgcGhpbSDEkcaw4bujYyBraHV54bq/biBuZ2jhu4sgbmhp4buBdSBs4bqnbiBuaOG6pXQgYuG7n2kgQ0ZSIEVuZ2luZSB24bubaSAxMCBs4bqnbik6IA0KDQpgYGB7cn0NCmRhdGEuZnJhbWUobW92aWVJZCA9IHVubGlzdChpdGVtc19wcmVkaWN0ZWRAaXRlbXMpKSAlPiUgDQogIGdyb3VwX2J5KG1vdmllSWQpICU+JSANCiAgY291bnQoKSAlPiUgDQogIHVuZ3JvdXAoKSAlPiUgDQogIHRvcF9uKG4gPSAyMCwgd3QgPSBuKSAlPiUgDQogIGFycmFuZ2UobikgJT4lIA0KICBpbm5lcl9qb2luKG1vdmllc19jb21tb24gJT4lIHNlbGVjdCgxOjIpKSAlPiUgDQogIG11dGF0ZSh0aXRsZSA9IGZhY3Rvcih0aXRsZSwgbGV2ZWxzID0gdGl0bGUpKSAtPiB0b3BfMjBfcmVjb21tZW5kZWQNCg0KdG9wXzIwX3JlY29tbWVuZGVkICU+JSANCiAgZ2dwbG90KGFlcyh0aXRsZSwgbikpICsgDQogIGdlb21fY29sKCkgKyANCiAgZ2VvbV90ZXh0KGFlcyhsYWJlbCA9IG4pLCBoanVzdCA9IDEuMywgY29sb3IgPSAid2hpdGUiKSArIA0KICBjb29yZF9mbGlwKCkgKyANCiAgbGFicyh4ID0gTlVMTCwgeSA9IE5VTEwsIHRpdGxlID0gIkZpZ3VyZSA3OiBUb3AtcmVjb21tZW5kZWQgTW92aWVzIGJ5IENGUiIpDQoNCiAgDQpgYGANCg0KWGVtIDMgYuG7mSBwaGltIG3DoCBFbmdpbmUga2h1eeG6v24gbmdo4buLIGNobywgdsOtIGThu6UsIHVzZXIgdGjhu6kgbmjhuqV0ICh0cm9uZyB0ZXN0IGRhdGEpOiANCg0KYGBge3J9DQptb3ZpZXNfcmVjb21tZW5kZWRfdXNlcjEgPC0gaXRlbXNfcHJlZGljdGVkQGl0ZW1zW1sxXV0NCg0KbW92aWVzX2NvbW1vbiAlPiUgDQogIGZpbHRlcihtb3ZpZUlkICVpbiUgbW92aWVzX3JlY29tbWVuZGVkX3VzZXIxKSAlPiUgDQogIHNlbGVjdChtb3ZpZUlkLCB0aXRsZSkNCg0KYGBgDQoNCiMgRmluYWwgTm90ZXMNCg0KUG9zdCBuw6B5IHRyw6xuaCB04bqtcCB0cnVuZyB2w6BvIHZp4buHYyB0aOG7sWMgaGnhu4duIHRp4buBbiB44butIGzDrSBz4buRIGxp4buHdSAtIGNodeG6qW4gYuG7iyBz4buRIGxp4buHdSBjaG8gxJHhur9uIHZp4buHYyB4w6J5IGThu7FuZyBt4buZdCBo4buHIHRo4buRbmcga2h1eeG6v24gbmdo4buLIGLhurFuZyBuZ8O0biBuZ+G7ryBSIG3DoCBj4bulIHRo4buDIGzDoCBDb2xsYWJvcmF0aXZlIEZpbHRlcmluZyBSZWNvbW1lbmRlci4gTeG7mXQgc+G7kSB24bqlbiDEkeG7gSBxdWFuIHRy4buNbmcgduG6q24gY2jGsGEgxJHGsOG7o2MgxJHhu4EgY+G6rXAvZ2nhuqNpIHF1eeG6v3QgbMOgOiANCg0KMS4gxJDDoW5oIGdpw6EgY2jhuqV0IGzGsOG7o25nIGPhu6dhIFJlY29tbWVuZGVyIEVuZ2luZS4gVsOtIGThu6UsIHPhu60gZOG7pW5nIENGUiBFbmdpbmUgxJHDoyBjw7MgY2jDum5nIHRhIMSRxrBhIHJhIGtodXnhur9uIGPDoW8gY+G7pSB0aOG7gyBiYSBi4buZIHBoaW0gbcOgIHVzZXIgdGjhu6kgbmjhuqV0IG7Dqm4geGVtLiBOaMawbmcgbMOgbSB0aOG6vyBuw6BvIMSR4buDIMSRw6FuaCBnacOhIGtodXnhur9uIG5naOG7iyBuw6B5IGPDsyBraOG7m3AgduG7m2kgbmh1IGPhuqd1IHbDoCBz4bufIHRow61jaCBj4bunYSBuZ8aw4budaSBuw6B5IGhheSBraMO0bmc/IA0KDQoyLiBN4buZdCBz4buRIGPDoWNoIHRp4bq/cCBj4bqtbiBraMOhYyBjaG8geMOieSBk4buxbmcgUmVjb21tZW5kZXIgRW5naW5lIGPFqW5nIG5oxrAgdGluaCBjaOG7iW5oIHRoYW0gc+G7kS4gDQoNCjMuIENo4bqldCBsxrDhu6NuZyBj4bunYSBFbmdpbmUgY8OybiBwaOG7pSB0aHXhu5ljIHLhuqV0IG5oaeG7gXUgbOG7sWEgY2jhu41uIGPDoWMgb2JzZXJ2YXRpb25zIHBow7kgaOG7o3AuIFbDrSBk4bulOiBt4buZdCBtw7QgaMOsbmggZOG7sWEgdHLDqm4gaMOgbmggdmkgY+G7p2EgdXNlciB0aMOsIHF1YW4gc8OhdCB24buBIHVzZXIgcGjhuqNpIMSR4bunIG5oaeG7gXUgxJHhu4MgZW5naW5lIGPDsyB0aOG7gyAidHLDrWNoIHh14bqldCIgcmEgaW5zaWdodHMvcGF0dGVybiDEkeG6oWkgZGnhu4duIGNobyBow6BuaCB2aSB0acOqdSBkw7luZyAodHJvbmcgdMOsbmggaHXhu5FuZyBuw6B5IGzDoCB4ZW0gcGhpbSkuIENGUiBFbmdpbmUgxJHGsOG7o2MgeMOieSBk4buxbmcgdHJvbmcgcG9zdCBuw6B5IHPhu60gZOG7pW5nIGPhuqMgbmjhu69uZyB1c2VyIG3DoCBo4buNIGNo4buJIHhlbSBt4buZdCB2w6BpIHBoaW0uIE5o4buvbmcgdXNlciBuw6B5IHF1w6Egw610IHTGsMahbmcgdMOhYyB2w6AgZG8gduG6rXkgaMOgbmggdmkgY+G7p2EgaOG7jSBraMO0bmcgcsO1IHLDoG5nIChob+G6t2Mga2jDtG5nIGPDsykgdsOsIHRo4bq/IHPhu60gZOG7pW5nIGPhuqMgbmjhu69uZyB1c2VyIG7DoHkga2hpIHjDonkgZOG7sW5nIFJlY29tbWVuZGVyIGPDsyB0aOG7gyBraMO0bmcgcGjDuSBo4bujcC4gDQoNCjQuIE7hur91IGPDsyBz4bq1biBk4buvIGxp4buHdSB24buBIHJhdGluZ3MgKGzDoCB0w6xuaCBodeG7kW5nIGPhu6dhIGLhu5kgZOG7ryBsaeG7h3Ugc+G7rSBk4bulbmcgdHJvbmcgcG9zdCBuw6B5KSB0aMOsIHZp4buHYyB4w6J5IGThu7FuZyBSZWNvbW1lbmRlciBFbmdpbmUgbMOgIHRodeG6rW4gdGnhu4duLiBUdXkgbmhpw6puIHRyb25nIHRo4buxYyB04bq/IHRow6wga2jDtG5nIHBo4bqjaSBsw7pjIG7DoG8gY8WpbmcgY8OzIGThu68gbGnhu4d1IHbhu4EgcmF0aW5nIHbDoCB4w6J5IGThu7FuZyBt4buZdCBSZWNvbW1lbmRlciBFbmdpbmUgbMO6YyBuw6B5IHRy4bufIG7Dqm4ga2jDsyBraMSDbiBoxqFuLiANCg0KIyBSZWZlcmVuY2VzDQoNCjEuIFtCdWlsZGluZyBSZWNvbW1lbmRlciBTeXN0ZW1zIHdpdGggTWFjaGluZSBMZWFybmluZyBhbmQgQUldKGh0dHBzOi8vd3d3LmFtYXpvbi5jb20vQnVpbGRpbmctUmVjb21tZW5kZXItU3lzdGVtcy1NYWNoaW5lLUxlYXJuaW5nL2RwLzE3MTgxMjAxMjUvcmVmPXNyXzFfNT9jcmlkPTNKSFUxRk5aODBNTSZkY2hpbGQ9MSZrZXl3b3Jkcz1yZWNvbW1lbmRhdGlvbitzeXN0ZW0mcWlkPTE1OTM1OTg5MDQmc3ByZWZpeD1yZWNvbW1lbmRhdGlvbitzeXMlMkNhcHMlMkMzODQmc3I9OC01KS4gDQoNCjIuIFtCdWlsZGluZyBhIFJlY29tbWVuZGF0aW9uIFN5c3RlbSB3aXRoIFJdKGh0dHBzOi8vd3d3LmFtYXpvbi5jb20vQnVpbGRpbmctUmVjb21tZW5kYXRpb24tU3lzdGVtLVN1cmVzaC1Hb3Jha2FsYS1lYm9vay9kcC9CMDEyTzhTMVlNL3JlZj1zcl8xXzY/Y3JpZD0zSkhVMUZOWjgwTU0mZGNoaWxkPTEma2V5d29yZHM9cmVjb21tZW5kYXRpb24rc3lzdGVtJnFpZD0xNTkzNTk4OTA0JnNwcmVmaXg9cmVjb21tZW5kYXRpb24rc3lzJTJDYXBzJTJDMzg0JnNyPTgtNikuIA0KDQozLiBbUHJhY3RpY2FsIFJlY29tbWVuZGVyIFN5c3RlbXNdKGh0dHBzOi8vd3d3LmFtYXpvbi5jb20vUHJhY3RpY2FsLVJlY29tbWVuZGVyLVN5c3RlbXMtS2ltLUZhbGsvZHAvMTYxNzI5MjcwMi9yZWY9c3JfMV80P2NyaWQ9M0pIVTFGTlo4ME1NJmRjaGlsZD0xJmtleXdvcmRzPXJlY29tbWVuZGF0aW9uK3N5c3RlbSZxaWQ9MTU5MzU5ODkwNCZzcHJlZml4PXJlY29tbWVuZGF0aW9uK3N5cyUyQ2FwcyUyQzM4NCZzcj04LTQpLiANCg0KNC4gW0hhbmRzLU9uIFJlY29tbWVuZGF0aW9uIFN5c3RlbXMgd2l0aCBQeXRob246IFN0YXJ0IGJ1aWxkaW5nIHBvd2VyZnVsIGFuZCBwZXJzb25hbGl6ZWQsIHJlY29tbWVuZGF0aW9uIGVuZ2luZXMgd2l0aCBQeXRob25dKGh0dHBzOi8vd3d3LmFtYXpvbi5jb20vSGFuZHMtUmVjb21tZW5kYXRpb24tU3lzdGVtcy1QeXRob24tcmVjb21tZW5kYXRpb24vZHAvMTc4ODk5Mzc1Ni9yZWY9c3JfMV8zP2NyaWQ9M0pIVTFGTlo4ME1NJmRjaGlsZD0xJmtleXdvcmRzPXJlY29tbWVuZGF0aW9uK3N5c3RlbSZxaWQ9MTU5MzU5ODkwNCZzcHJlZml4PXJlY29tbWVuZGF0aW9uK3N5cyUyQ2FwcyUyQzM4NCZzcj04LTMpLiANCg0KNS4gW0V2YWx1YXRpb24gTWV0cmljcyBmb3IgUmVjb21tZW5kZXIgU3lzdGVtc10oaHR0cHM6Ly90b3dhcmRzZGF0YXNjaWVuY2UuY29tL2V2YWx1YXRpb24tbWV0cmljcy1mb3ItcmVjb21tZW5kZXItc3lzdGVtcy1kZjU2YzY2MTEwOTMpLiANCg0KNi4gW0V2YWx1YXRpbmcgUmVjb21tZW5kYXRpb24gU3lzdGVtc10oaHR0cDovL3d3dy5iZ3UuYWMuaWwvfnNoYW5pZ3UvUHVibGljYXRpb25zL0V2YWx1YXRpb25NZXRyaWNzLjE3LnBkZikuIA0KDQo3LiBbQSBTdXJ2ZXkgb2YgQWNjdXJhY3kgRXZhbHVhdGlvbiBNZXRyaWNzIG9mIFJlY29tbWVuZGF0aW9uIFRhc2tzXShodHRwOi8vam1sci5jc2FpbC5taXQuZWR1L3BhcGVycy92b2x1bWUxMC9ndW5hd2FyZGFuYTA5YS9ndW5hd2FyZGFuYTA5YS5wZGYpLiANCg0KDQoNCg0KDQoNCg0KDQo=