Different Types of Recommenders

Các hệ thống khuyến nghị (Recommendation System / Recommender) được xây dựng dựa trên ba hướng tiếp cận dưới đây:

  • Collaborative Filtering
  • Content-Based Filtering
  • Hybrid Recommendation Systems

Collaborative Filtering (CF) là hướng tiếp cận mà chỉ dựa vào dữ liệu về hành vi tiêu dùng của người sử dụng trong khi Content-Based Filtering lại chỉ dựa vào các đặc điểm / đặc trưng của sản phầm. Cách tiếp cận thứ ba - Hybrid Recommendation Systems, thì lại dựa vào đồng thời cả dữ liệu về hành vi của người tiêu dùng và các đặc điểm của sản phẩm. Một ví dụ là hệ thống khuyến nghị của Netflix được sử dụng để khuyến nghị các bộ phim mà người dùng nên xem.

Collaborative Filtering lại có hai hướng tiếp cận riêng biệt là User-base Collaborative FilteringItem-base Collaborative Filtering. Giải thích tương đối chi tiết về hai hướng tiếp cận này bạn đọc có thể tham khảo ở đây, ở đâyở đây.

Trong Part 1Part 2 chúng ta đã train một Recommender tương ứng với hai tình huống: (1) có dữ liệu về ratings - tình huống lí tưởng, và (2) không có dữ liệu về ratings - một tình huống hay gặp trong thực tế. Cả hai Recommender này đều thuộc User-base Collaborative Filtering (thể hiện qua method = "IBCF" khi train Recommender).

Tất cả cách tiếp cận để xây dựng hệ thống khuyến nghị đều dựa trên một thước đo được gọi là Similarity Measure - mức độ tương đồng và việc lựa chọn Similarity Measure phù hợp sẽ ảnh hưởng đến chất lượng của hệ thống khuyến nghị.

Cosin Similarity

Trong số các Similarity Measures đã đề cập ở trên thì Cosin Similarity là thước đo có khả năng áp dụng trong nhiều tình huống: từ dữ liệu có ratings lẫn không có ratings và cũng có thể sử dụng cho Content-Based Filtering và Hybrid Recommendation Systems. Dưới đây chúng ta sẽ viết một hàm tính Cosin Similarity (với giả định rằng bạn đọc đã biết công thức toán học của thước đo này):

# Clear workspace: 

rm(list = ls())

# Function calculating cosine similarity beween two vectors: 

cosine_similiraty <- function(x, y) {
  cosine_sim <- crossprod(x, y) / sqrt(crossprod(x)*crossprod(y))
  return(cosine_sim)
}

Chúng ta áp dụng hàm đã viết để tính cosin similarity của hai vectors:

# An example of cosin similiraty: 

vec1 <- c( 1, 1, 1, 0, 0, 0) 

vec2 <- c( 0, 1, 1, 1, 0, 1)

cosine_similiraty(vec1, vec2)
##           [,1]
## [1,] 0.5773503

Như đã biết, chúng ta có thể sử dụng hàm similarity() của thư viện recommenderlab để tính ra kết quả 0.5773503 này:

# Compare with similarity() function: 

library(recommenderlab)

similarity(as(as.matrix(cbind(vec1, vec2)), "realRatingMatrix"), which = "items")
##           vec1
## vec2 0.5773503

Mục tiêu đặt ra là đưa ra 5 items có mức độ tương đầu cao nhất với một item bất kì cho trước trong bộ dữ liệu MovieLens Data Set đã từng được sử dụng ở Part 1. Trước hết cần load, transformation data về ma trận thưa (Sparse Matrix) từ dữ liệu thô (Raw Data) ban đầu:

# Import data: 

library(tidyverse)

ratings <- read_csv("ratings.csv")

# Convert to real time: 

library(lubridate)

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

# Convert to sparse matrix: 

ratings %>% 
  select(-timestamp) %>% 
  spread(key = movieId, value = rating, fill = 0) -> sparse_user_item

# Users: 

sparse_user_item$userId -> all_users

# Convert to sparse matrix: 

sparse_user_item %>% 
  select(-userId) %>% 
  as.matrix() -> sparse_matrix_user_item

# Set row names for sparse matrix: 

rownames(sparse_matrix_user_item) <- all_users

# Columns names (movieIds): 

col_names <- colnames(sparse_matrix_user_item)

# Some observations: 

sparse_matrix_user_item[1:5, 1:5]
##   1 2 3 4 5
## 1 4 0 4 0 0
## 2 0 0 0 0 0
## 3 0 0 0 0 0
## 4 0 0 0 0 0
## 5 4 0 0 0 0

Kế tiếp viết hàm đưa ra 5 items có độ tương đồng cao nhất với một item cho trước:

# Function calculates the similarity between a chosen movie and the other movies
# and recommend 5 new songs with 5-highest similarities: 

return_5_highest_sim <- function(movie_id) {
  
  apply(sparse_matrix_user_item, 2, function(i) {cosine_similiraty(sparse_matrix_user_item[, colnames(sparse_matrix_user_item) == movie_id], i)}) -> sim
  
  tibble(movieId = names(sim), similiraty = sim) -> df_sim
  
  df_sim %>% 
    mutate(movieId = as.numeric(movieId)) %>% 
    top_n(6, wt = similiraty) %>% 
    arrange(-similiraty) %>% 
    ungroup() %>% 
    mutate(reference = case_when(movieId == movie_id ~ "Yes", TRUE ~ "No")) %>% 
    return()
}

Chúng ta có thể sử dụng hàm này để list ra danh sách 5 movies/items tương đồng nhất với, ví dụ, bộ phim thứ nhất:

# Find 5-highest similarities for the first movieId: 

return_5_highest_sim(movie_id = col_names[1]) -> df_1

# Show results: 

library(knitr)

kable(df_1)
movieId similiraty reference
1 1.0000000 Yes
3114 0.5726013 No
480 0.5656368 No
780 0.5642617 No
260 0.5573882 No
356 0.5470959 No

Phim có movieId là 1 thì đương nhiên đồng nhất với chính nó và do vậy similiraty = 1. Phim có movieId = 3114 và movieId = 1 có similarity = 0.5726. Chúng ta mapping với bộ dữ liệu mô tả về các bộ phim này để biết rõ hơn:

# Movie descriptions: 

descriptions <- read_csv("movies.csv")

# Map the two data sets: 

inner_join(df_1, descriptions, by = "movieId") -> df_1_des

# Show results: 

df_1_des %>% 
  select(-genres, -reference) %>% 
  kable()
movieId similiraty title
1 1.0000000 Toy Story (1995)
3114 0.5726013 Toy Story 2 (1999)
480 0.5656368 Jurassic Park (1993)
780 0.5642617 Independence Day (a.k.a. ID4) (1996)
260 0.5573882 Star Wars: Episode IV - A New Hope (1977)
356 0.5470959 Forrest Gump (1994)

Kết quả khá sát với kì vọng của chúng ta: Toy Story 2 (1999) sản xuất năm 1999 tương đồng cao nhất Toy Story (1995) - bộ phim được chọn làm reference. Còn Jurassic Park (1993) là bộ phim tương đồng thứ hai. Kết quả này ngụ ý rằng: trong danh các bộ phim mà một user nào đó đã xem mà có Toy Story (1995) thì hệ thống khuyến nghị nên trước hết đề xuất Toy Story 2 (1999) để người này xem cộng với 4 movies khác nữa. Phần dưới đây sẽ đưa ra một cách tiếp cận để đánh giá hiệu quả hoạt động của Recommender.

Item-base Recommender Engine using Cosin Similarity

Như đã trình bày trong Part 2 thì hiệu quả của Recommender Engine sẽ được đánh giá theo cách tiếp cận sau: các movies được khuyến nghị cho user sẽ dựa trên data quan sát thấy từ 2018-01-01 trở về trước còn data sau ngày 2018-01-01 sẽ được sử dụng để test:

# Set time point: 

time_selected <- ymd("2018-01-01")

# Split data for train and test engine: 

train_raw_data <- ratings %>% filter(timestamp <= time_selected)

test_raw_data <- ratings %>% filter(timestamp > time_selected)

Không phải user hay item nào cũng sử dụng cho huấn luyện Recommender Engine. Chẳng hạn, với user thì chúng ta chỉ lấy những user nào xem ít nhất 5 bộ phim còn movie thì phải là những phim được xem ít nhất 5 lần. Để thuận lợi chúng ta sẽ viết một hàm convert dữ liệu thô ban đầu về Sparse Matrix với các inputs sau:

  • Dữ liệu thô được chọn.
  • Số bộ phim tối thiểu mà một user phải xem để user đó được chọn.
  • Số lần tối thiểu mà một movie được xem để movie đó được chọn.
# Function convert to sparse matrix: 

convert_to_sparse_matrix <- function(df_selected, min_movie_viewed_by_user, min_freq_for_movie) {
  
  # Only select users and items that sastify satisfy min thresholds: 

  df_selected %>% 
    group_by(userId) %>% 
    count() %>% 
    ungroup() %>% 
    filter(n >= min_movie_viewed_by_user) %>% 
    pull(userId) -> users_selected
  
  df_selected %>% 
    group_by(movieId) %>% 
    count() %>% 
    ungroup() %>% 
    filter(n >= min_freq_for_movie) %>% 
    pull(movieId) -> movies_selected
  
  # Filter raw data: 
  
  df_selected %>% 
    filter(userId %in% users_selected) %>% 
    filter(movieId %in% movies_selected) -> df_final
  
  # Convert to sparse matrix: 
  
  df_final %>% 
    select(-timestamp) %>% 
    spread(key = movieId, value = rating, fill = 0) -> sparse_user_item

  # Convert to sparse matrix: 

  sparse_user_item %>% 
    select(-userId) %>% 
    as.matrix() -> sparse_matrix_user_item

  # Set row names for sparse matrix: 

  rownames(sparse_matrix_user_item) <- users_selected

  # Return result: 
  
  return(list(raw_data = df_final, train_matrix = sparse_matrix_user_item))
  
}

Sử dụng hàm đã có để convert về sparse matrix cho train_raw_data:

# Convert to sparse matrix: 

convert_to_sparse_matrix(df_selected = train_raw_data, 
                         min_movie_viewed_by_user = 5, 
                         min_freq_for_movie = 5) -> list_results


# Extract raw data and sparse matrix: 

train_matrix <- list_results$train_matrix

df_for_converting <- list_results$raw_data

# All movies and users from train data: 

all_movies <- colnames(train_matrix)

all_users <- row.names(train_matrix)

Viết hàm return_5_highest_sim2() tương tự như return_5_highest_sim() với một số hiệu chỉnh. Hàm này trả về 5 movies có tương đồng cao nhất với một movie cho trước:

return_5_highest_sim2 <- function(movie_id) {
  
  apply(train_matrix, 2, function(i) {cosine_similiraty(train_matrix[, colnames(train_matrix) == movie_id], i)}) -> sim
  
  tibble(movieId = names(sim), similiraty = sim) -> df_sim
  
  df_sim %>% 
    mutate(movieId = as.numeric(movieId)) %>% 
    top_n(6, wt = similiraty) %>% 
    arrange(-similiraty) %>% 
    ungroup() %>% 
    mutate(reference = case_when(movieId == movie_id ~ "Yes", TRUE ~ "No")) %>% 
    mutate(movieId_ref = movie_id) %>% 
    return()
}

Sử dụng hàm đã có để đưa ra 5 items tương đồng cao nhất cho, ví dụ, 3 movies bất kì:

lapply(sample(1:length(all_movies), 3, replace = FALSE), function(x) {return_5_highest_sim2(all_movies[x])}) -> list_movies_recom
lapply(list_movies_recom, function(df) {inner_join(df, descriptions %>% select(-genres), by = "movieId")})
## [[1]]
## # A tibble: 6 x 5
##   movieId similiraty reference movieId_ref title                           
##     <dbl>      <dbl> <chr>     <chr>       <chr>                           
## 1    1688      1     Yes       1688        Anastasia (1997)                
## 2    1566      0.540 No        1688        Hercules (1997)                 
## 3    1907      0.494 No        1688        Mulan (1998)                    
## 4    4016      0.467 No        1688        Emperor's New Groove, The (2000)
## 5    2096      0.466 No        1688        Sleeping Beauty (1959)          
## 6    1588      0.458 No        1688        George of the Jungle (1997)     
## 
## [[2]]
## # A tibble: 6 x 5
##   movieId similiraty reference movieId_ref title                   
##     <dbl>      <dbl> <chr>     <chr>       <chr>                   
## 1   45431      1     Yes       45431       Over the Hedge (2006)   
## 2   93326      0.610 No        45431       This Means War (2012)   
## 3    6595      0.556 No        45431       S.W.A.T. (2003)         
## 4    7004      0.526 No        45431       Kindergarten Cop (1990) 
## 5   60126      0.520 No        45431       Get Smart (2008)        
## 6    8972      0.512 No        45431       National Treasure (2004)
## 
## [[3]]
## # A tibble: 6 x 5
##   movieId similiraty reference movieId_ref title                   
##     <dbl>      <dbl> <chr>     <chr>       <chr>                   
## 1   48142      1     Yes       48142       Black Dahlia, The (2006)
## 2   41716      0.652 No        48142       Matador, The (2005)     
## 3    6755      0.645 No        48142       Bubba Ho-tep (2002)     
## 4    5009      0.596 No        48142       Ali (2001)              
## 5   39381      0.594 No        48142       Proposition, The (2005) 
## 6   30894      0.585 No        48142       White Noise (2005)

Sử dụng những kết quả này để khuyến nghị những movies nào cho user và đánh giá chất lượng của Recommender sẽ được giải thích ở phần dưới đây.

Evaluating Recommender Performance

Cách tiếp cận để đánh giá hệ thống khuyến nghị đã được trình bày trong Part 2. Để thực hiện thực hóa cách tiếp cận này trước hết chúng ta viết hàm mà khuyến nghị 5 movies cho một user bất kì được chọn căn cứ theo user ID:

show_5_movies_for_user <- function(user_id) {
  
  df_for_converting %>% 
    filter(userId == user_id) %>% 
    filter(!duplicated(movieId)) %>% 
    pull(movieId) %>% 
    as.character() -> movies_by_user
  
  lapply(movies_by_user, return_5_highest_sim2) -> list_movies_reco_for_user

  do.call("bind_rows", list_movies_reco_for_user) -> df_reco
  
  df_reco %>% 
    filter(reference != "Yes") %>% 
    top_n(n = 5, wt = similiraty) %>% 
    select(movieId) %>% 
    mutate(userId = user_id) %>% 
    return()
  
}

Sử dụng hàm này để đưa ra movies được khuyến nghị cho tất cả users (có thể mất thời gian để chạy):

# All users will viewed movies after 2018-01-01: 

all_user_future <- test_raw_data$userId %>% unique() %>% as.character()

base::intersect(all_user_future, all_users) -> all_user_future

# Movies recommended for those users: 

system.time(lapply(all_user_future, show_5_movies_for_user) -> list_movies_recom_for_all)
##    user  system elapsed 
## 2504.31   15.43 2545.22

Chúng ta có thể xem 5 items cho 2 userID đầu tiên:

list_movies_recom_for_all[1:2]
## [[1]]
## # A tibble: 6 x 2
##   movieId userId
##     <dbl> <chr> 
## 1    7153 18    
## 2    5952 18    
## 3    7153 18    
## 4    4993 18    
## 5    5952 18    
## 6    4993 18    
## 
## [[2]]
## # A tibble: 5 x 2
##   movieId userId
##     <dbl> <chr> 
## 1    1221 50    
## 2    7438 50    
## 3    6874 50    
## 4  102033 50    
## 5  174055 50

Với userId = 50 thì các movieId được khuyến nghị là 1221, 7438, 6874, 102033 và 174055. Chúng ta sẽ phải kiểm tra xem trong “giỏ hàng” mà khách hàng này mua sau khi có khuyến nghị (tức sau 2018-01-01) có những movies này hay không.

# Movies viewed after 2018-01-01: 

test_raw_data %>% 
  filter(userId == all_user_future[2]) %>% 
  pull(movieId) %>% 
  unique() -> movieId_for_this_user

# Number of recommended moveies that this user viewed: 
sum(list_movies_recom_for_all[[2]] %>% pull(movieId) %in% movieId_for_this_user)
## [1] 1

Kết quả này chỉ ra rằng user đó có xem 1 trong số những movies được khuyến nghị. Để đánh giá kết quả hoạt động của Recommender trước hết chúng ta viết hàm tính số lượng movies mà users xem:

number_movies_viewed_from_recommender <- function(user_i_th) {
  
  test_raw_data %>% 
  filter(userId == all_user_future[user_i_th]) %>% 
  pull(movieId) %>% 
  unique() -> movieId_for_this_user
  
  sum(list_movies_recom_for_all[[user_i_th]] %>% pull(movieId) %in% movieId_for_this_user) -> n
  
  return(n)
}

Sử dụng hàm này để tính, ví dụ, tỉ lệ users sẽ xem ít nhất một movie được khuyến nghị từ Recommender:

sum(sapply(1:length(all_user_future), number_movies_viewed_from_recommender) != 0) / length(all_user_future)
## [1] 0.15

Kết quả này nghĩa là 15% số users sẽ xem ít nhất một movie mà hệ thống khuyến nghị cho họ.

Conclusions

  • 15% là tỉ lệ cao hay thấp thì còn tùy thuộc vào mục tiêu ban đầu đặt ra khi xây dựng Recommender Engine. Mặt khác, tỉ lệ này sẽ cao hơn nếu, ví dụ, hệ thống đưa ra không chỉ 5 mà là 10 movies khuyến nghị cho users.

  • Để có đánh giá toàn diện làm căn cứ lựa chọn Recommander thì cần phải so sánh các Recommanders dựa trên các thước đo khác nhau về Similarity - điều mà post này chưa giải quyết.

LS0tDQp0aXRsZTogJ1JlY29tbWVuZGF0aW9uIFN5c3RlbSAoUGFydCAzKScNCmF1dGhvcjogJ0F1dGhvcjogTmd1eWVuIENoaSBEdW5nJw0Kc3VidGl0bGU6ICJSIE1hY2hpbmUgTGVhcm5pbmcgU2VyaWVzIg0Kb3V0cHV0Og0KICBodG1sX2RvY3VtZW50OiANCiAgICBjb2RlX2Rvd25sb2FkOiB0cnVlDQogICAgIyBjb2RlX2ZvbGRpbmc6IGhpZGUNCiAgICBoaWdobGlnaHQ6IHplbmJ1cm4NCiAgICAjIG51bWJlcl9zZWN0aW9uczogeWVzDQogICAgdGhlbWU6ICJmbGF0bHkiDQogICAgdG9jOiBUUlVFDQogICAgdG9jX2Zsb2F0OiBUUlVFDQotLS0NCg0KYGBge3Igc2V0dXAsaW5jbHVkZT1GQUxTRX0NCmtuaXRyOjpvcHRzX2NodW5rJHNldChlY2hvID0gVFJVRSwgd2FybmluZyA9IEZBTFNFLCBtZXNzYWdlID0gRkFMU0UsIGNhY2hlID0gVFJVRSkNCg0KIyBodHRwczovL3N0ZWZhbnNhdmV2LmNvbS9ibG9nL2Nvc2luZS1zaW1pbGFyaXR5LWFsbC1wb3N0cy8NCiMgaHR0cHM6Ly9tYXNvbmdhbGxvLmdpdGh1Yi5pby9tYWNoaW5lL2xlYXJuaW5nLC9weXRob24vMjAxNi8wNy8yOS9jb3NpbmUtc2ltaWxhcml0eS5odG1sDQojIGh0dHBzOi8vdG93YXJkc2RhdGFzY2llbmNlLmNvbS93aGF0LWFyZS1wcm9kdWN0LXJlY29tbWVuZGF0aW9uLWVuZ2luZXMtYW5kLXRoZS12YXJpb3VzLXZlcnNpb25zLW9mLXRoZW0tOWRjYWI0ZWUyNmQ1DQojIGh0dHBzOi8vaGFja2Vybm9vbi5jb20vaW50cm9kdWN0aW9uLXRvLXJlY29tbWVuZGVyLXN5c3RlbS1wYXJ0LTEtY29sbGFib3JhdGl2ZS1maWx0ZXJpbmctc2luZ3VsYXItdmFsdWUtZGVjb21wb3NpdGlvbi00NGM5NjU5YzVlNzUNCg0KYGBgDQoNCg0KIyBEaWZmZXJlbnQgVHlwZXMgb2YgUmVjb21tZW5kZXJzDQoNCkPDoWMgaOG7hyB0aOG7kW5nIGtodXnhur9uIG5naOG7iyAoUmVjb21tZW5kYXRpb24gU3lzdGVtIC8gUmVjb21tZW5kZXIpIMSRxrDhu6NjIHjDonkgZOG7sW5nIGThu7FhIHRyw6puIGJhIGjGsOG7m25nIHRp4bq/cCBj4bqtbiBkxrDhu5tpIMSRw6J5Og0KDQotICoqQ29sbGFib3JhdGl2ZSBGaWx0ZXJpbmcqKg0KLSAqKkNvbnRlbnQtQmFzZWQgRmlsdGVyaW5nKioNCi0gKipIeWJyaWQgUmVjb21tZW5kYXRpb24gU3lzdGVtcyoqDQoNCkNvbGxhYm9yYXRpdmUgRmlsdGVyaW5nIChDRikgbMOgIGjGsOG7m25nIHRp4bq/cCBj4bqtbiBtw6AgY2jhu4kgZOG7sWEgdsOgbyAqZOG7ryBsaeG7h3UgduG7gSBow6BuaCB2aSB0acOqdSBkw7luZyBj4bunYSBuZ8aw4budaSBz4butIGThu6VuZyogdHJvbmcga2hpIENvbnRlbnQtQmFzZWQgRmlsdGVyaW5nIGzhuqFpIGNo4buJIGThu7FhIHbDoG8gKmPDoWMgxJHhurdjIMSRaeG7g20gLyDEkeG6t2MgdHLGsG5nIGPhu6dhIHPhuqNuIHBo4bqnbSouIEPDoWNoIHRp4bq/cCBj4bqtbiB0aOG7qSBiYSAtIEh5YnJpZCBSZWNvbW1lbmRhdGlvbiBTeXN0ZW1zLCB0aMOsIGzhuqFpIGThu7FhIHbDoG8gxJHhu5NuZyB0aOG7nWkgY+G6oyAqZOG7ryBsaeG7h3UgduG7gSBow6BuaCB2aSBj4bunYSBuZ8aw4budaSB0acOqdSBkw7luZyB2w6AgY8OhYyDEkeG6t2MgxJFp4buDbSBj4bunYSBz4bqjbiBwaOG6qW0qLiBN4buZdCB2w60gZOG7pSBsw6AgW2jhu4cgdGjhu5FuZyBraHV54bq/biBuZ2jhu4sgY+G7p2EgTmV0ZmxpeF0oaHR0cHM6Ly9lbi53aWtpcGVkaWEub3JnL3dpa2kvUmVjb21tZW5kZXJfc3lzdGVtKSDEkcaw4bujYyBz4butIGThu6VuZyDEkeG7gyBraHV54bq/biBuZ2jhu4sgY8OhYyBi4buZIHBoaW0gbcOgIG5nxrDhu51pIGTDuW5nIG7Dqm4geGVtLiANCg0KDQpDb2xsYWJvcmF0aXZlIEZpbHRlcmluZyBs4bqhaSBjw7MgaGFpIGjGsOG7m25nIHRp4bq/cCBj4bqtbiByacOqbmcgYmnhu4d0IGzDoCAqVXNlci1iYXNlIENvbGxhYm9yYXRpdmUgRmlsdGVyaW5nKiB2w6AgKkl0ZW0tYmFzZSBDb2xsYWJvcmF0aXZlIEZpbHRlcmluZyouIEdp4bqjaSB0aMOtY2ggdMawxqFuZyDEkeG7kWkgY2hpIHRp4bq/dCB24buBIGhhaSBoxrDhu5tuZyB0aeG6v3AgY+G6rW4gbsOgeSBi4bqhbiDEkeG7jWMgY8OzIHRo4buDIHRoYW0ga2jhuqNvIFvhu58gxJHDonldKGh0dHBzOi8vaGFja2Vybm9vbi5jb20vaW50cm9kdWN0aW9uLXRvLXJlY29tbWVuZGVyLXN5c3RlbS1wYXJ0LTEtY29sbGFib3JhdGl2ZS1maWx0ZXJpbmctc2luZ3VsYXItdmFsdWUtZGVjb21wb3NpdGlvbi00NGM5NjU5YzVlNzUpLCBb4bufIMSRw6J5XShodHRwczovL3Rvd2FyZHNkYXRhc2NpZW5jZS5jb20vd2hhdC1hcmUtcHJvZHVjdC1yZWNvbW1lbmRhdGlvbi1lbmdpbmVzLWFuZC10aGUtdmFyaW91cy12ZXJzaW9ucy1vZi10aGVtLTlkY2FiNGVlMjZkNSkgdsOgIFvhu58gxJHDonldKGh0dHBzOi8vbWVkaXVtLmNvbS9AY2ZwaW5lbGEvcmVjb21tZW5kZXItc3lzdGVtcy11c2VyLWJhc2VkLWFuZC1pdGVtLWJhc2VkLWNvbGxhYm9yYXRpdmUtZmlsdGVyaW5nLTVkNWYzNzVhMTI3ZikuICAgIA0KDQpUcm9uZyBbUGFydCAxXShodHRwczovL3JwdWJzLmNvbS9jaGlkdW5na3QvNjM0MzAwKSB2w6AgW1BhcnQgMl0oaHR0cHM6Ly9ycHVicy5jb20vY2hpZHVuZ2t0LzYzODc2MCkgY2jDum5nIHRhIMSRw6MgdHJhaW4gbeG7mXQgUmVjb21tZW5kZXIgdMawxqFuZyDhu6luZyB24bubaSBoYWkgdMOsbmggaHXhu5FuZzogKDEpIGPDsyBk4buvIGxp4buHdSB24buBIHJhdGluZ3MgLSB0w6xuaCBodeG7kW5nIGzDrSB0xrDhu59uZywgdsOgICgyKSBraMO0bmcgY8OzIGThu68gbGnhu4d1IHbhu4EgcmF0aW5ncyAtIG3hu5l0IHTDrG5oIGh14buRbmcgaGF5IGfhurdwIHRyb25nIHRo4buxYyB04bq/LiBD4bqjIGhhaSBSZWNvbW1lbmRlciBuw6B5IMSR4buBdSB0aHXhu5ljIFVzZXItYmFzZSBDb2xsYWJvcmF0aXZlIEZpbHRlcmluZyAodGjhu4MgaGnhu4duIHF1YSBgbWV0aG9kID0gIklCQ0YiYCBraGkgdHJhaW4gUmVjb21tZW5kZXIpLiANCg0KVOG6pXQgY+G6oyBjw6FjaCB0aeG6v3AgY+G6rW4gxJHhu4MgeMOieSBk4buxbmcgaOG7hyB0aOG7kW5nIGtodXnhur9uIG5naOG7iyDEkeG7gXUgZOG7sWEgdHLDqm4gbeG7mXQgdGjGsOG7m2MgxJFvIMSRxrDhu6NjIGfhu41pIGzDoCBbU2ltaWxhcml0eSBNZWFzdXJlIC0gbeG7qWMgxJHhu5kgdMawxqFuZyDEkeG7k25nXShodHRwczovL2RhdGFhc3BpcmFudC5jb20vZml2ZS1tb3N0LXBvcHVsYXItc2ltaWxhcml0eS1tZWFzdXJlcy1pbXBsZW1lbnRhdGlvbi1pbi1weXRob24vKSB2w6Agdmnhu4djIGzhu7FhIGNo4buNbiBTaW1pbGFyaXR5IE1lYXN1cmUgcGjDuSBo4bujcCBz4bq9IOG6o25oIGjGsOG7n25nIMSR4bq/biBjaOG6pXQgbMaw4bujbmcgY+G7p2EgaOG7hyB0aOG7kW5nIGtodXnhur9uIG5naOG7iy4gDQoNCiMgQ29zaW4gU2ltaWxhcml0eQ0KDQpUcm9uZyBz4buRIGPDoWMgU2ltaWxhcml0eSBNZWFzdXJlcyDEkcOjIMSR4buBIGPhuq1wIOG7nyB0csOqbiB0aMOsIFtDb3NpbiBTaW1pbGFyaXR5XShodHRwczovL2VuLndpa2lwZWRpYS5vcmcvd2lraS9Db3NpbmVfc2ltaWxhcml0eSkgbMOgIHRoxrDhu5tjIMSRbyBjw7Mga2jhuqMgbsSDbmcgw6FwIGThu6VuZyB0cm9uZyBuaGnhu4F1IHTDrG5oIGh14buRbmc6IHThu6sgZOG7ryBsaeG7h3UgY8OzIHJhdGluZ3MgbOG6q24ga2jDtG5nIGPDsyByYXRpbmdzIHbDoCBjxaluZyBjw7MgdGjhu4Mgc+G7rSBk4bulbmcgY2hvIENvbnRlbnQtQmFzZWQgRmlsdGVyaW5nIHbDoCBIeWJyaWQgUmVjb21tZW5kYXRpb24gU3lzdGVtcy4gRMaw4bubaSDEkcOieSBjaMO6bmcgdGEgc+G6vSB2aeG6v3QgbeG7mXQgaMOgbSB0w61uaCBDb3NpbiBTaW1pbGFyaXR5ICh24bubaSBnaeG6oyDEkeG7i25oIHLhurFuZyBi4bqhbiDEkeG7jWMgxJHDoyBiaeG6v3QgY8O0bmcgdGjhu6ljIHRvw6FuIGjhu41jIGPhu6dhIHRoxrDhu5tjIMSRbyBuw6B5KTogDQoNCmBgYHtyfQ0KIyBDbGVhciB3b3Jrc3BhY2U6IA0KDQpybShsaXN0ID0gbHMoKSkNCg0KIyBGdW5jdGlvbiBjYWxjdWxhdGluZyBjb3NpbmUgc2ltaWxhcml0eSBiZXdlZW4gdHdvIHZlY3RvcnM6IA0KDQpjb3NpbmVfc2ltaWxpcmF0eSA8LSBmdW5jdGlvbih4LCB5KSB7DQogIGNvc2luZV9zaW0gPC0gY3Jvc3Nwcm9kKHgsIHkpIC8gc3FydChjcm9zc3Byb2QoeCkqY3Jvc3Nwcm9kKHkpKQ0KICByZXR1cm4oY29zaW5lX3NpbSkNCn0NCg0KYGBgDQoNCkNow7puZyB0YSDDoXAgZOG7pW5nIGjDoG0gxJHDoyB2aeG6v3QgxJHhu4MgdMOtbmggY29zaW4gc2ltaWxhcml0eSBj4bunYSBoYWkgdmVjdG9yczogDQoNCmBgYHtyfQ0KDQojIEFuIGV4YW1wbGUgb2YgY29zaW4gc2ltaWxpcmF0eTogDQoNCnZlYzEgPC0gYyggMSwgMSwgMSwgMCwgMCwgMCkgDQoNCnZlYzIgPC0gYyggMCwgMSwgMSwgMSwgMCwgMSkNCg0KY29zaW5lX3NpbWlsaXJhdHkodmVjMSwgdmVjMikNCmBgYA0KDQpOaMawIMSRw6MgYmnhur90LCBjaMO6bmcgdGEgY8OzIHRo4buDIHPhu60gZOG7pW5nIGjDoG0gYHNpbWlsYXJpdHkoKWAgY+G7p2EgdGjGsCB2aeG7h24gKipyZWNvbW1lbmRlcmxhYioqIMSR4buDIHTDrW5oIHJhIGvhur90IHF14bqjIDAuNTc3MzUwMyBuw6B5OiANCg0KYGBge3J9DQoNCiMgQ29tcGFyZSB3aXRoIHNpbWlsYXJpdHkoKSBmdW5jdGlvbjogDQoNCmxpYnJhcnkocmVjb21tZW5kZXJsYWIpDQoNCnNpbWlsYXJpdHkoYXMoYXMubWF0cml4KGNiaW5kKHZlYzEsIHZlYzIpKSwgInJlYWxSYXRpbmdNYXRyaXgiKSwgd2hpY2ggPSAiaXRlbXMiKQ0KDQpgYGANCg0KTeG7pWMgdGnDqnUgxJHhurd0IHJhIGzDoCAqKsSRxrBhIHJhIDUgaXRlbXMgY8OzIG3hu6ljIMSR4buZIHTGsMahbmcgxJHhuqd1IGNhbyBuaOG6pXQgduG7m2kgbeG7mXQgaXRlbSBi4bqldCBrw6wgY2hvIHRyxrDhu5tjKiogdHJvbmcgYuG7mSBk4buvIGxp4buHdSBbTW92aWVMZW5zIERhdGEgU2V0XShodHRwczovL2dyb3VwbGVucy5vcmcvZGF0YXNldHMvbW92aWVsZW5zL2xhdGVzdC8pIMSRw6MgdOG7q25nIMSRxrDhu6NjIHPhu60gZOG7pW5nIOG7nyBQYXJ0IDEuIFRyxrDhu5tjIGjhur90IGPhuqduIGxvYWQsIHRyYW5zZm9ybWF0aW9uIGRhdGEgduG7gSBtYSB0cuG6rW4gdGjGsGEgKFNwYXJzZSBNYXRyaXgpIHThu6sgZOG7ryBsaeG7h3UgdGjDtCAoUmF3IERhdGEpIGJhbiDEkeG6p3U6IA0KDQpgYGB7cn0NCg0KIyBJbXBvcnQgZGF0YTogDQoNCmxpYnJhcnkodGlkeXZlcnNlKQ0KDQpyYXRpbmdzIDwtIHJlYWRfY3N2KCJyYXRpbmdzLmNzdiIpDQoNCiMgQ29udmVydCB0byByZWFsIHRpbWU6IA0KDQpsaWJyYXJ5KGx1YnJpZGF0ZSkNCg0KcmF0aW5ncyAlPiUgDQogIG11dGF0ZSh0aW1lc3RhbXAgPSBhc19kYXRldGltZSh0aW1lc3RhbXApLCB0aW1lc3RhbXAgPSBkYXRlKHRpbWVzdGFtcCkpIC0+IHJhdGluZ3MNCg0KIyBDb252ZXJ0IHRvIHNwYXJzZSBtYXRyaXg6IA0KDQpyYXRpbmdzICU+JSANCiAgc2VsZWN0KC10aW1lc3RhbXApICU+JSANCiAgc3ByZWFkKGtleSA9IG1vdmllSWQsIHZhbHVlID0gcmF0aW5nLCBmaWxsID0gMCkgLT4gc3BhcnNlX3VzZXJfaXRlbQ0KDQojIFVzZXJzOiANCg0Kc3BhcnNlX3VzZXJfaXRlbSR1c2VySWQgLT4gYWxsX3VzZXJzDQoNCiMgQ29udmVydCB0byBzcGFyc2UgbWF0cml4OiANCg0Kc3BhcnNlX3VzZXJfaXRlbSAlPiUgDQogIHNlbGVjdCgtdXNlcklkKSAlPiUgDQogIGFzLm1hdHJpeCgpIC0+IHNwYXJzZV9tYXRyaXhfdXNlcl9pdGVtDQoNCiMgU2V0IHJvdyBuYW1lcyBmb3Igc3BhcnNlIG1hdHJpeDogDQoNCnJvd25hbWVzKHNwYXJzZV9tYXRyaXhfdXNlcl9pdGVtKSA8LSBhbGxfdXNlcnMNCg0KIyBDb2x1bW5zIG5hbWVzIChtb3ZpZUlkcyk6IA0KDQpjb2xfbmFtZXMgPC0gY29sbmFtZXMoc3BhcnNlX21hdHJpeF91c2VyX2l0ZW0pDQoNCiMgU29tZSBvYnNlcnZhdGlvbnM6IA0KDQpzcGFyc2VfbWF0cml4X3VzZXJfaXRlbVsxOjUsIDE6NV0NCg0KYGBgDQoNCkvhur8gdGnhur9wIHZp4bq/dCBow6BtIMSRxrBhIHJhIDUgaXRlbXMgY8OzIMSR4buZIHTGsMahbmcgxJHhu5NuZyBjYW8gbmjhuqV0IHbhu5tpIG3hu5l0IGl0ZW0gY2hvIHRyxrDhu5tjOiANCg0KYGBge3J9DQoNCiMgRnVuY3Rpb24gY2FsY3VsYXRlcyB0aGUgc2ltaWxhcml0eSBiZXR3ZWVuIGEgY2hvc2VuIG1vdmllIGFuZCB0aGUgb3RoZXIgbW92aWVzDQojIGFuZCByZWNvbW1lbmQgNSBuZXcgc29uZ3Mgd2l0aCA1LWhpZ2hlc3Qgc2ltaWxhcml0aWVzOiANCg0KcmV0dXJuXzVfaGlnaGVzdF9zaW0gPC0gZnVuY3Rpb24obW92aWVfaWQpIHsNCiAgDQogIGFwcGx5KHNwYXJzZV9tYXRyaXhfdXNlcl9pdGVtLCAyLCBmdW5jdGlvbihpKSB7Y29zaW5lX3NpbWlsaXJhdHkoc3BhcnNlX21hdHJpeF91c2VyX2l0ZW1bLCBjb2xuYW1lcyhzcGFyc2VfbWF0cml4X3VzZXJfaXRlbSkgPT0gbW92aWVfaWRdLCBpKX0pIC0+IHNpbQ0KICANCiAgdGliYmxlKG1vdmllSWQgPSBuYW1lcyhzaW0pLCBzaW1pbGlyYXR5ID0gc2ltKSAtPiBkZl9zaW0NCiAgDQogIGRmX3NpbSAlPiUgDQogICAgbXV0YXRlKG1vdmllSWQgPSBhcy5udW1lcmljKG1vdmllSWQpKSAlPiUgDQogICAgdG9wX24oNiwgd3QgPSBzaW1pbGlyYXR5KSAlPiUgDQogICAgYXJyYW5nZSgtc2ltaWxpcmF0eSkgJT4lIA0KICAgIHVuZ3JvdXAoKSAlPiUgDQogICAgbXV0YXRlKHJlZmVyZW5jZSA9IGNhc2Vfd2hlbihtb3ZpZUlkID09IG1vdmllX2lkIH4gIlllcyIsIFRSVUUgfiAiTm8iKSkgJT4lIA0KICAgIHJldHVybigpDQp9DQpgYGANCg0KQ2jDum5nIHRhIGPDsyB0aOG7gyBz4butIGThu6VuZyBow6BtIG7DoHkgxJHhu4MgbGlzdCByYSBkYW5oIHPDoWNoIDUgbW92aWVzL2l0ZW1zIHTGsMahbmcgxJHhu5NuZyBuaOG6pXQgduG7m2ksIHbDrSBk4bulLCBi4buZIHBoaW0gdGjhu6kgbmjhuqV0OiANCg0KYGBge3J9DQojIEZpbmQgNS1oaWdoZXN0IHNpbWlsYXJpdGllcyBmb3IgdGhlIGZpcnN0IG1vdmllSWQ6IA0KDQpyZXR1cm5fNV9oaWdoZXN0X3NpbShtb3ZpZV9pZCA9IGNvbF9uYW1lc1sxXSkgLT4gZGZfMQ0KDQojIFNob3cgcmVzdWx0czogDQoNCmxpYnJhcnkoa25pdHIpDQoNCmthYmxlKGRmXzEpDQoNCmBgYA0KDQpQaGltIGPDsyBtb3ZpZUlkIGzDoCAxIHRow6wgxJHGsMahbmcgbmhpw6puIMSR4buTbmcgbmjhuqV0IHbhu5tpIGNow61uaCBuw7MgdsOgIGRvIHbhuq15IHNpbWlsaXJhdHkgPSAxLiBQaGltIGPDsyBtb3ZpZUlkID0gMzExNCB2w6AgbW92aWVJZCA9IDEgY8OzIHNpbWlsYXJpdHkgPSAwLjU3MjYuIENow7puZyB0YSBtYXBwaW5nIHbhu5tpIGLhu5kgZOG7ryBsaeG7h3UgbcO0IHThuqMgduG7gSBjw6FjIGLhu5kgcGhpbSBuw6B5IMSR4buDIGJp4bq/dCByw7UgaMahbjogDQoNCmBgYHtyfQ0KIyBNb3ZpZSBkZXNjcmlwdGlvbnM6IA0KDQpkZXNjcmlwdGlvbnMgPC0gcmVhZF9jc3YoIm1vdmllcy5jc3YiKQ0KDQojIE1hcCB0aGUgdHdvIGRhdGEgc2V0czogDQoNCmlubmVyX2pvaW4oZGZfMSwgZGVzY3JpcHRpb25zLCBieSA9ICJtb3ZpZUlkIikgLT4gZGZfMV9kZXMNCg0KIyBTaG93IHJlc3VsdHM6IA0KDQpkZl8xX2RlcyAlPiUgDQogIHNlbGVjdCgtZ2VucmVzLCAtcmVmZXJlbmNlKSAlPiUgDQogIGthYmxlKCkNCg0KYGBgDQoNCkvhur90IHF14bqjIGtow6Egc8OhdCB24bubaSBrw6wgduG7jW5nIGPhu6dhIGNow7puZyB0YTogVG95IFN0b3J5IDIgKDE5OTkpIHPhuqNuIHh14bqldCBuxINtIDE5OTkgdMawxqFuZyDEkeG7k25nIGNhbyBuaOG6pXQgVG95IFN0b3J5ICgxOTk1KSAtIGLhu5kgcGhpbSDEkcaw4bujYyBjaOG7jW4gbMOgbSByZWZlcmVuY2UuIEPDsm4gSnVyYXNzaWMgUGFyayAoMTk5MykgbMOgIGLhu5kgcGhpbSB0xrDGoW5nIMSR4buTbmcgdGjhu6kgaGFpLiBL4bq/dCBxdeG6oyBuw6B5IG5n4bulIMO9IHLhurFuZzogKip0cm9uZyBkYW5oIGPDoWMgYuG7mSBwaGltIG3DoCBt4buZdCB1c2VyIG7DoG8gxJHDsyDEkcOjIHhlbSBtw6AgY8OzIFRveSBTdG9yeSAoMTk5NSkgdGjDrCBo4buHIHRo4buRbmcga2h1eeG6v24gbmdo4buLIG7Dqm4gdHLGsOG7m2MgaOG6v3QgxJHhu4EgeHXhuqV0IFRveSBTdG9yeSAyICgxOTk5KSDEkeG7gyBuZ8aw4budaSBuw6B5IHhlbSBj4buZbmcgduG7m2kgNCBtb3ZpZXMga2jDoWMgbuG7r2EqKi4gUGjhuqduIGTGsOG7m2kgxJHDonkgc+G6vSDEkcawYSByYSBt4buZdCBjw6FjaCB0aeG6v3AgY+G6rW4gxJHhu4MgxJHDoW5oIGdpw6EgaGnhu4d1IHF14bqjIGhv4bqhdCDEkeG7mW5nIGPhu6dhIFJlY29tbWVuZGVyLiANCg0KIyBJdGVtLWJhc2UgUmVjb21tZW5kZXIgRW5naW5lIHVzaW5nIENvc2luIFNpbWlsYXJpdHkNCg0KTmjGsCDEkcOjIHRyw6xuaCBiw6B5IHRyb25nIFBhcnQgMiB0aMOsIGhp4buHdSBxdeG6oyBj4bunYSBSZWNvbW1lbmRlciBFbmdpbmUgc+G6vSDEkcaw4bujYyDEkcOhbmggZ2nDoSB0aGVvIGPDoWNoIHRp4bq/cCBj4bqtbiBzYXU6IGPDoWMgbW92aWVzIMSRxrDhu6NjIGtodXnhur9uIG5naOG7iyBjaG8gdXNlciBz4bq9IGThu7FhIHRyw6puIGRhdGEgcXVhbiBzw6F0IHRo4bqleSB04burIDIwMTgtMDEtMDEgdHLhu58gduG7gSB0csaw4bubYyBjw7JuIGRhdGEgc2F1IG5nw6B5IDIwMTgtMDEtMDEgc+G6vSDEkcaw4bujYyBz4butIGThu6VuZyDEkeG7gyB0ZXN0OiANCg0KYGBge3J9DQojIFNldCB0aW1lIHBvaW50OiANCg0KdGltZV9zZWxlY3RlZCA8LSB5bWQoIjIwMTgtMDEtMDEiKQ0KDQojIFNwbGl0IGRhdGEgZm9yIHRyYWluIGFuZCB0ZXN0IGVuZ2luZTogDQoNCnRyYWluX3Jhd19kYXRhIDwtIHJhdGluZ3MgJT4lIGZpbHRlcih0aW1lc3RhbXAgPD0gdGltZV9zZWxlY3RlZCkNCg0KdGVzdF9yYXdfZGF0YSA8LSByYXRpbmdzICU+JSBmaWx0ZXIodGltZXN0YW1wID4gdGltZV9zZWxlY3RlZCkNCg0KYGBgDQoNCktow7RuZyBwaOG6o2kgdXNlciBoYXkgaXRlbSBuw6BvIGPFqW5nIHPhu60gZOG7pW5nIGNobyBodeG6pW4gbHV54buHbiBSZWNvbW1lbmRlciBFbmdpbmUuIENo4bqzbmcgaOG6oW4sIHbhu5tpIHVzZXIgdGjDrCBjaMO6bmcgdGEgY2jhu4kgbOG6pXkgbmjhu69uZyB1c2VyIG7DoG8geGVtIMOtdCBuaOG6pXQgNSBi4buZIHBoaW0gY8OybiBtb3ZpZSB0aMOsIHBo4bqjaSBsw6Agbmjhu69uZyBwaGltIMSRxrDhu6NjIHhlbSDDrXQgbmjhuqV0IDUgbOG6p24uIMSQ4buDIHRodeG6rW4gbOG7o2kgY2jDum5nIHRhIHPhur0gdmnhur90IG3hu5l0IGjDoG0gY29udmVydCBk4buvIGxp4buHdSB0aMO0IGJhbiDEkeG6p3UgduG7gSBTcGFyc2UgTWF0cml4IHbhu5tpIGPDoWMgaW5wdXRzIHNhdTogDQoNCi0gROG7ryBsaeG7h3UgdGjDtCDEkcaw4bujYyBjaOG7jW4uIA0KLSBT4buRIGLhu5kgcGhpbSB04buRaSB0aGnhu4N1IG3DoCBt4buZdCB1c2VyIHBo4bqjaSB4ZW0gxJHhu4MgdXNlciDEkcOzIMSRxrDhu6NjIGNo4buNbi4gDQotIFPhu5EgbOG6p24gdOG7kWkgdGhp4buDdSBtw6AgbeG7mXQgbW92aWUgxJHGsOG7o2MgeGVtIMSR4buDIG1vdmllIMSRw7MgxJHGsOG7o2MgY2jhu41uLiANCg0KDQpgYGB7cn0NCiMgRnVuY3Rpb24gY29udmVydCB0byBzcGFyc2UgbWF0cml4OiANCg0KY29udmVydF90b19zcGFyc2VfbWF0cml4IDwtIGZ1bmN0aW9uKGRmX3NlbGVjdGVkLCBtaW5fbW92aWVfdmlld2VkX2J5X3VzZXIsIG1pbl9mcmVxX2Zvcl9tb3ZpZSkgew0KICANCiAgIyBPbmx5IHNlbGVjdCB1c2VycyBhbmQgaXRlbXMgdGhhdCBzYXN0aWZ5IHNhdGlzZnkgbWluIHRocmVzaG9sZHM6IA0KDQogIGRmX3NlbGVjdGVkICU+JSANCiAgICBncm91cF9ieSh1c2VySWQpICU+JSANCiAgICBjb3VudCgpICU+JSANCiAgICB1bmdyb3VwKCkgJT4lIA0KICAgIGZpbHRlcihuID49IG1pbl9tb3ZpZV92aWV3ZWRfYnlfdXNlcikgJT4lIA0KICAgIHB1bGwodXNlcklkKSAtPiB1c2Vyc19zZWxlY3RlZA0KICANCiAgZGZfc2VsZWN0ZWQgJT4lIA0KICAgIGdyb3VwX2J5KG1vdmllSWQpICU+JSANCiAgICBjb3VudCgpICU+JSANCiAgICB1bmdyb3VwKCkgJT4lIA0KICAgIGZpbHRlcihuID49IG1pbl9mcmVxX2Zvcl9tb3ZpZSkgJT4lIA0KICAgIHB1bGwobW92aWVJZCkgLT4gbW92aWVzX3NlbGVjdGVkDQogIA0KICAjIEZpbHRlciByYXcgZGF0YTogDQogIA0KICBkZl9zZWxlY3RlZCAlPiUgDQogICAgZmlsdGVyKHVzZXJJZCAlaW4lIHVzZXJzX3NlbGVjdGVkKSAlPiUgDQogICAgZmlsdGVyKG1vdmllSWQgJWluJSBtb3ZpZXNfc2VsZWN0ZWQpIC0+IGRmX2ZpbmFsDQogIA0KICAjIENvbnZlcnQgdG8gc3BhcnNlIG1hdHJpeDogDQogIA0KICBkZl9maW5hbCAlPiUgDQogICAgc2VsZWN0KC10aW1lc3RhbXApICU+JSANCiAgICBzcHJlYWQoa2V5ID0gbW92aWVJZCwgdmFsdWUgPSByYXRpbmcsIGZpbGwgPSAwKSAtPiBzcGFyc2VfdXNlcl9pdGVtDQoNCiAgIyBDb252ZXJ0IHRvIHNwYXJzZSBtYXRyaXg6IA0KDQogIHNwYXJzZV91c2VyX2l0ZW0gJT4lIA0KICAgIHNlbGVjdCgtdXNlcklkKSAlPiUgDQogICAgYXMubWF0cml4KCkgLT4gc3BhcnNlX21hdHJpeF91c2VyX2l0ZW0NCg0KICAjIFNldCByb3cgbmFtZXMgZm9yIHNwYXJzZSBtYXRyaXg6IA0KDQogIHJvd25hbWVzKHNwYXJzZV9tYXRyaXhfdXNlcl9pdGVtKSA8LSB1c2Vyc19zZWxlY3RlZA0KDQogICMgUmV0dXJuIHJlc3VsdDogDQogIA0KICByZXR1cm4obGlzdChyYXdfZGF0YSA9IGRmX2ZpbmFsLCB0cmFpbl9tYXRyaXggPSBzcGFyc2VfbWF0cml4X3VzZXJfaXRlbSkpDQogIA0KfQ0KDQpgYGANCg0KU+G7rSBk4bulbmcgaMOgbSDEkcOjIGPDsyDEkeG7gyBjb252ZXJ0IHbhu4Egc3BhcnNlIG1hdHJpeCBjaG8gdHJhaW5fcmF3X2RhdGE6IA0KDQpgYGB7cn0NCiMgQ29udmVydCB0byBzcGFyc2UgbWF0cml4OiANCg0KY29udmVydF90b19zcGFyc2VfbWF0cml4KGRmX3NlbGVjdGVkID0gdHJhaW5fcmF3X2RhdGEsIA0KICAgICAgICAgICAgICAgICAgICAgICAgIG1pbl9tb3ZpZV92aWV3ZWRfYnlfdXNlciA9IDUsIA0KICAgICAgICAgICAgICAgICAgICAgICAgIG1pbl9mcmVxX2Zvcl9tb3ZpZSA9IDUpIC0+IGxpc3RfcmVzdWx0cw0KDQoNCiMgRXh0cmFjdCByYXcgZGF0YSBhbmQgc3BhcnNlIG1hdHJpeDogDQoNCnRyYWluX21hdHJpeCA8LSBsaXN0X3Jlc3VsdHMkdHJhaW5fbWF0cml4DQoNCmRmX2Zvcl9jb252ZXJ0aW5nIDwtIGxpc3RfcmVzdWx0cyRyYXdfZGF0YQ0KDQojIEFsbCBtb3ZpZXMgYW5kIHVzZXJzIGZyb20gdHJhaW4gZGF0YTogDQoNCmFsbF9tb3ZpZXMgPC0gY29sbmFtZXModHJhaW5fbWF0cml4KQ0KDQphbGxfdXNlcnMgPC0gcm93Lm5hbWVzKHRyYWluX21hdHJpeCkNCmBgYA0KDQpWaeG6v3QgaMOgbSBgcmV0dXJuXzVfaGlnaGVzdF9zaW0yKClgIHTGsMahbmcgdOG7sSBuaMawIGByZXR1cm5fNV9oaWdoZXN0X3NpbSgpYCB24bubaSBt4buZdCBz4buRIGhp4buHdSBjaOG7iW5oLiBIw6BtIG7DoHkgdHLhuqMgduG7gSA1IG1vdmllcyBjw7MgdMawxqFuZyDEkeG7k25nIGNhbyBuaOG6pXQgduG7m2kgbeG7mXQgbW92aWUgY2hvIHRyxrDhu5tjOiANCg0KYGBge3J9DQoNCnJldHVybl81X2hpZ2hlc3Rfc2ltMiA8LSBmdW5jdGlvbihtb3ZpZV9pZCkgew0KICANCiAgYXBwbHkodHJhaW5fbWF0cml4LCAyLCBmdW5jdGlvbihpKSB7Y29zaW5lX3NpbWlsaXJhdHkodHJhaW5fbWF0cml4WywgY29sbmFtZXModHJhaW5fbWF0cml4KSA9PSBtb3ZpZV9pZF0sIGkpfSkgLT4gc2ltDQogIA0KICB0aWJibGUobW92aWVJZCA9IG5hbWVzKHNpbSksIHNpbWlsaXJhdHkgPSBzaW0pIC0+IGRmX3NpbQ0KICANCiAgZGZfc2ltICU+JSANCiAgICBtdXRhdGUobW92aWVJZCA9IGFzLm51bWVyaWMobW92aWVJZCkpICU+JSANCiAgICB0b3Bfbig2LCB3dCA9IHNpbWlsaXJhdHkpICU+JSANCiAgICBhcnJhbmdlKC1zaW1pbGlyYXR5KSAlPiUgDQogICAgdW5ncm91cCgpICU+JSANCiAgICBtdXRhdGUocmVmZXJlbmNlID0gY2FzZV93aGVuKG1vdmllSWQgPT0gbW92aWVfaWQgfiAiWWVzIiwgVFJVRSB+ICJObyIpKSAlPiUgDQogICAgbXV0YXRlKG1vdmllSWRfcmVmID0gbW92aWVfaWQpICU+JSANCiAgICByZXR1cm4oKQ0KfQ0KYGBgDQoNCg0KU+G7rSBk4bulbmcgaMOgbSDEkcOjIGPDsyDEkeG7gyDEkcawYSByYSA1IGl0ZW1zIHTGsMahbmcgxJHhu5NuZyBjYW8gbmjhuqV0IGNobywgdsOtIGThu6UsIDMgbW92aWVzIGLhuqV0IGvDrDogDQoNCmBgYHtyfQ0KbGFwcGx5KHNhbXBsZSgxOmxlbmd0aChhbGxfbW92aWVzKSwgMywgcmVwbGFjZSA9IEZBTFNFKSwgZnVuY3Rpb24oeCkge3JldHVybl81X2hpZ2hlc3Rfc2ltMihhbGxfbW92aWVzW3hdKX0pIC0+IGxpc3RfbW92aWVzX3JlY29tDQpsYXBwbHkobGlzdF9tb3ZpZXNfcmVjb20sIGZ1bmN0aW9uKGRmKSB7aW5uZXJfam9pbihkZiwgZGVzY3JpcHRpb25zICU+JSBzZWxlY3QoLWdlbnJlcyksIGJ5ID0gIm1vdmllSWQiKX0pDQoNCmBgYA0KDQpT4butIGThu6VuZyBuaOG7r25nIGvhur90IHF14bqjIG7DoHkgxJHhu4Mga2h1eeG6v24gbmdo4buLIG5o4buvbmcgbW92aWVzIG7DoG8gY2hvIHVzZXIgdsOgIMSRw6FuaCBnacOhIGNo4bqldCBsxrDhu6NuZyBj4bunYSBSZWNvbW1lbmRlciBz4bq9IMSRxrDhu6NjIGdp4bqjaSB0aMOtY2gg4bufIHBo4bqnbiBkxrDhu5tpIMSRw6J5LiANCg0KIyBFdmFsdWF0aW5nIFJlY29tbWVuZGVyIFBlcmZvcm1hbmNlDQoNCkPDoWNoIHRp4bq/cCBj4bqtbiDEkeG7gyDEkcOhbmggZ2nDoSBo4buHIHRo4buRbmcga2h1eeG6v24gbmdo4buLIMSRw6MgxJHGsOG7o2MgdHLDrG5oIGLDoHkgdHJvbmcgW1BhcnQgMl0oaHR0cHM6Ly9ycHVicy5jb20vY2hpZHVuZ2t0LzYzODc2MCkuIMSQ4buDIHRo4buxYyBoaeG7h24gdGjhu7FjIGjDs2EgY8OhY2ggdGnhur9wIGPhuq1uIG7DoHkgdHLGsOG7m2MgaOG6v3QgY2jDum5nIHRhIHZp4bq/dCBow6BtIG3DoCBraHV54bq/biBuZ2jhu4sgNSBtb3ZpZXMgY2hvIG3hu5l0IHVzZXIgYuG6pXQga8OsIMSRxrDhu6NjIGNo4buNbiBjxINuIGPhu6kgdGhlbyB1c2VyIElEOiANCg0KYGBge3J9DQoNCnNob3dfNV9tb3ZpZXNfZm9yX3VzZXIgPC0gZnVuY3Rpb24odXNlcl9pZCkgew0KICANCiAgZGZfZm9yX2NvbnZlcnRpbmcgJT4lIA0KICAgIGZpbHRlcih1c2VySWQgPT0gdXNlcl9pZCkgJT4lIA0KICAgIGZpbHRlcighZHVwbGljYXRlZChtb3ZpZUlkKSkgJT4lIA0KICAgIHB1bGwobW92aWVJZCkgJT4lIA0KICAgIGFzLmNoYXJhY3RlcigpIC0+IG1vdmllc19ieV91c2VyDQogIA0KICBsYXBwbHkobW92aWVzX2J5X3VzZXIsIHJldHVybl81X2hpZ2hlc3Rfc2ltMikgLT4gbGlzdF9tb3ZpZXNfcmVjb19mb3JfdXNlcg0KDQogIGRvLmNhbGwoImJpbmRfcm93cyIsIGxpc3RfbW92aWVzX3JlY29fZm9yX3VzZXIpIC0+IGRmX3JlY28NCiAgDQogIGRmX3JlY28gJT4lIA0KICAgIGZpbHRlcihyZWZlcmVuY2UgIT0gIlllcyIpICU+JSANCiAgICB0b3BfbihuID0gNSwgd3QgPSBzaW1pbGlyYXR5KSAlPiUgDQogICAgc2VsZWN0KG1vdmllSWQpICU+JSANCiAgICBtdXRhdGUodXNlcklkID0gdXNlcl9pZCkgJT4lIA0KICAgIHJldHVybigpDQogIA0KfQ0KDQpgYGANCg0KU+G7rSBk4bulbmcgaMOgbSBuw6B5IMSR4buDIMSRxrBhIHJhIG1vdmllcyDEkcaw4bujYyBraHV54bq/biBuZ2jhu4sgY2hvIHThuqV0IGPhuqMgdXNlcnMgKGPDsyB0aOG7gyBt4bqldCB0aOG7nWkgZ2lhbiDEkeG7gyBjaOG6oXkpOiANCg0KYGBge3J9DQojIEFsbCB1c2VycyB3aWxsIHZpZXdlZCBtb3ZpZXMgYWZ0ZXIgMjAxOC0wMS0wMTogDQoNCmFsbF91c2VyX2Z1dHVyZSA8LSB0ZXN0X3Jhd19kYXRhJHVzZXJJZCAlPiUgdW5pcXVlKCkgJT4lIGFzLmNoYXJhY3RlcigpDQoNCmJhc2U6OmludGVyc2VjdChhbGxfdXNlcl9mdXR1cmUsIGFsbF91c2VycykgLT4gYWxsX3VzZXJfZnV0dXJlDQoNCiMgTW92aWVzIHJlY29tbWVuZGVkIGZvciB0aG9zZSB1c2VyczogDQoNCnN5c3RlbS50aW1lKGxhcHBseShhbGxfdXNlcl9mdXR1cmUsIHNob3dfNV9tb3ZpZXNfZm9yX3VzZXIpIC0+IGxpc3RfbW92aWVzX3JlY29tX2Zvcl9hbGwpDQoNCmBgYA0KDQpDaMO6bmcgdGEgY8OzIHRo4buDIHhlbSA1IGl0ZW1zIGNobyAyIHVzZXJJRCDEkeG6p3UgdGnDqm46IA0KDQpgYGB7cn0NCmxpc3RfbW92aWVzX3JlY29tX2Zvcl9hbGxbMToyXQ0KYGBgDQoNClbhu5tpIHVzZXJJZCA9IDUwIHRow6wgY8OhYyBtb3ZpZUlkIMSRxrDhu6NjIGtodXnhur9uIG5naOG7iyBsw6AgMTIyMSwgNzQzOCwgNjg3NCwgMTAyMDMzIHbDoCAxNzQwNTUuIENow7puZyB0YSBz4bq9IHBo4bqjaSBraeG7g20gdHJhIHhlbSB0cm9uZyAiZ2nhu48gaMOgbmciIG3DoCBraMOhY2ggaMOgbmcgbsOgeSBtdWEgc2F1IGtoaSBjw7Mga2h1eeG6v24gbmdo4buLICh04bupYyBzYXUgMjAxOC0wMS0wMSkgY8OzIG5o4buvbmcgbW92aWVzIG7DoHkgaGF5IGtow7RuZy4gDQoNCg0KYGBge3J9DQoNCiMgTW92aWVzIHZpZXdlZCBhZnRlciAyMDE4LTAxLTAxOiANCg0KdGVzdF9yYXdfZGF0YSAlPiUgDQogIGZpbHRlcih1c2VySWQgPT0gYWxsX3VzZXJfZnV0dXJlWzJdKSAlPiUgDQogIHB1bGwobW92aWVJZCkgJT4lIA0KICB1bmlxdWUoKSAtPiBtb3ZpZUlkX2Zvcl90aGlzX3VzZXINCg0KIyBOdW1iZXIgb2YgcmVjb21tZW5kZWQgbW92ZWllcyB0aGF0IHRoaXMgdXNlciB2aWV3ZWQ6IA0Kc3VtKGxpc3RfbW92aWVzX3JlY29tX2Zvcl9hbGxbWzJdXSAlPiUgcHVsbChtb3ZpZUlkKSAlaW4lIG1vdmllSWRfZm9yX3RoaXNfdXNlcikNCmBgYA0KDQpL4bq/dCBxdeG6oyBuw6B5IGNo4buJIHJhIHLhurFuZyB1c2VyIMSRw7MgY8OzIHhlbSAxIHRyb25nIHPhu5Egbmjhu69uZyBtb3ZpZXMgxJHGsOG7o2Mga2h1eeG6v24gbmdo4buLLiDEkOG7gyDEkcOhbmggZ2nDoSBr4bq/dCBxdeG6oyBob+G6oXQgxJHhu5luZyBj4bunYSBSZWNvbW1lbmRlciB0csaw4bubYyBo4bq/dCBjaMO6bmcgdGEgdmnhur90IGjDoG0gdMOtbmggc+G7kSBsxrDhu6NuZyBtb3ZpZXMgbcOgIHVzZXJzIHhlbTogDQoNCmBgYHtyfQ0KbnVtYmVyX21vdmllc192aWV3ZWRfZnJvbV9yZWNvbW1lbmRlciA8LSBmdW5jdGlvbih1c2VyX2lfdGgpIHsNCiAgDQogIHRlc3RfcmF3X2RhdGEgJT4lIA0KICBmaWx0ZXIodXNlcklkID09IGFsbF91c2VyX2Z1dHVyZVt1c2VyX2lfdGhdKSAlPiUgDQogIHB1bGwobW92aWVJZCkgJT4lIA0KICB1bmlxdWUoKSAtPiBtb3ZpZUlkX2Zvcl90aGlzX3VzZXINCiAgDQogIHN1bShsaXN0X21vdmllc19yZWNvbV9mb3JfYWxsW1t1c2VyX2lfdGhdXSAlPiUgcHVsbChtb3ZpZUlkKSAlaW4lIG1vdmllSWRfZm9yX3RoaXNfdXNlcikgLT4gbg0KICANCiAgcmV0dXJuKG4pDQp9DQpgYGANCg0KU+G7rSBk4bulbmcgaMOgbSBuw6B5IMSR4buDIHTDrW5oLCB2w60gZOG7pSwgdOG7iSBs4buHIHVzZXJzIHPhur0geGVtIMOtdCBuaOG6pXQgbeG7mXQgbW92aWUgxJHGsOG7o2Mga2h1eeG6v24gbmdo4buLIHThu6sgUmVjb21tZW5kZXI6IA0KDQpgYGB7cn0NCnN1bShzYXBwbHkoMTpsZW5ndGgoYWxsX3VzZXJfZnV0dXJlKSwgbnVtYmVyX21vdmllc192aWV3ZWRfZnJvbV9yZWNvbW1lbmRlcikgIT0gMCkgLyBsZW5ndGgoYWxsX3VzZXJfZnV0dXJlKQ0KYGBgDQoNCkvhur90IHF14bqjIG7DoHkgbmdoxKlhIGzDoCAxNSUgc+G7kSB1c2VycyBz4bq9IHhlbSDDrXQgbmjhuqV0IG3hu5l0IG1vdmllIG3DoCBo4buHIHRo4buRbmcga2h1eeG6v24gbmdo4buLIGNobyBo4buNLiANCg0KIyBDb25jbHVzaW9ucw0KDQotIDE1JSBsw6AgdOG7iSBs4buHIGNhbyBoYXkgdGjhuqVwIHRow6wgY8OybiB0w7l5IHRodeG7mWMgdsOgbyBt4bulYyB0acOqdSBiYW4gxJHhuqd1IMSR4bq3dCByYSBraGkgeMOieSBk4buxbmcgUmVjb21tZW5kZXIgRW5naW5lLiBN4bq3dCBraMOhYywgdOG7iSBs4buHIG7DoHkgc+G6vSBjYW8gaMahbiBu4bq/dSwgdsOtIGThu6UsIGjhu4cgdGjhu5FuZyDEkcawYSByYSBraMO0bmcgY2jhu4kgNSBtw6AgbMOgIDEwIG1vdmllcyBraHV54bq/biBuZ2jhu4sgY2hvIHVzZXJzLiANCg0KLSDEkOG7gyBjw7MgxJHDoW5oIGdpw6EgdG/DoG4gZGnhu4duIGzDoG0gY8SDbiBj4bupIGzhu7FhIGNo4buNbiBSZWNvbW1hbmRlciB0aMOsIGPhuqduIHBo4bqjaSBzbyBzw6FuaCBjw6FjIFJlY29tbWFuZGVycyBk4buxYSB0csOqbiBjw6FjIHRoxrDhu5tjIMSRbyBraMOhYyBuaGF1IHbhu4EgU2ltaWxhcml0eSAtIMSRaeG7gXUgbcOgIHBvc3QgbsOgeSBjaMawYSBnaeG6o2kgcXV54bq/dC4gDQoNCiMgUmVmZXJlbmNlcw0KDQoxLiBbQSBTdXJ2ZXkgb2YgQWNjdXJhY3kgRXZhbHVhdGlvbiBNZXRyaWNzIG9mIFJlY29tbWVuZGF0aW9uIFRhc2tzXShodHRwOi8vam1sci5jc2FpbC5taXQuZWR1L3BhcGVycy92b2x1bWUxMC9ndW5hd2FyZGFuYTA5YS9ndW5hd2FyZGFuYTA5YS5wZGYpLiANCjIuIFtFdmFsdWF0aW5nIFJlY29tbWVuZGF0aW9uIFN5c3RlbXNdKGh0dHA6Ly93d3cuYmd1LmFjLmlsL35zaGFuaWd1L1B1YmxpY2F0aW9ucy9FdmFsdWF0aW9uTWV0cmljcy4xNy5wZGYpLiANCjMuIFtFdmFsdWF0aW5nIENvbGxhYm9yYXRpdmUgRmlsdGVyaW5nIFJlY29tbWVuZGVyIFN5c3RlbXNdKGh0dHBzOi8vZ3JvdXBsZW5zLm9yZy9zaXRlLWNvbnRlbnQvdXBsb2Fkcy9ldmFsdWF0aW5nLVRPSVMtMjAwNDEucGRmKS4gDQozLiBbUmVjb21tZW5kZXIgU3lzdGVtczogQW4gSW50cm9kdWN0aW9uXShodHRwczovL3d3dy5hbWF6b24uY29tL1JlY29tbWVuZGVyLVN5c3RlbXMtSW50cm9kdWN0aW9uLURpZXRtYXItSmFubmFjaC9kcC8wNTIxNDkzMzY2L3JlZj1wZF9zYnNfMTRfNz9fZW5jb2Rpbmc9VVRGOCZwZF9yZF9pPTA1MjE0OTMzNjYmcGRfcmRfcj1jODFlYWNhNy0zNTNhLTQzNjctYTliMS1mYTVlNzdkODQyMmMmcGRfcmRfdz1HeTNPViZwZF9yZF93Zz15QnlXVyZwZl9yZF9wPWJjMDc0MDUxLTgxZDEtNDg3NC1hM2ZkLWZkMGM4NjdjZTNiNCZwZl9yZF9yPVMyUktUOVZXUFhOWUNZOVFQRU1XJnBzYz0xJnJlZlJJRD1TMlJLVDlWV1BYTllDWTlRUEVNVykuIA0KDQoNCg0KDQoNCg0KDQoNCg==