Introduction

Về Recommendation System và xây dựng Recommender trong các tình huống thường phát sinh trong thực tế (có ratings, không có ratings cũng như đánh giá hiệu quả của Recommender) các bạn có thể tham khảo Part 1, Part 2Part 3. Trong phần này chúng ta sẽ xây dựng Recommender Engine dựa trên Jaccard Similarity áp dụng cho Binary Data - vốn là một tình huống phổ biến khi mà dữ liệu về items là không có ratings tương ứng.

About Jaccard Similarity

Xem xét một ví dụ giả định về 3 JAV Fants và 6 diễn viên yêu thích của họ như sau:

# Clear workspace: 

rm(list = ls())

#============================
#    Jaccard Similarity
#============================

# Create a fake data that describes movies viewed by 3 users: 

user1 <- rep(0:1, each = 1, time = 3)
user2 <- c(0, rep(1, 3), 0, 1)
user3 <- c(1, 0, 0, 0, 1, 1)

row_names <- c("Maria Ozawa", 
               "Eimi Fukada", 
               "Nozomi Sasaki", 
               "Sora Aoi", 
               "Minori Hatsune",
               "Ria Sakurai")

df_fake <- data.frame(user1 = user1, user2 = user2, user3 = user3)

row.names(df_fake) <- row_names

# Show data: 

df_fake
##                user1 user2 user3
## Maria Ozawa        0     0     1
## Eimi Fukada        1     1     0
## Nozomi Sasaki      0     1     0
## Sora Aoi           1     1     0
## Minori Hatsune     0     0     1
## Ria Sakurai        1     1     1

Với user1 thì Eimi Fukada, Sora Aoi, Ria Sakurai là ba diễn viên yêu thích của người này (đánh số 1). Các diễn viên khác đánh số 0 nghĩa là chưa thích. Chúng ta có thể tính mức độ tương đồng của user1 này với hai users còn lại theo thước đo Jaccard Similarity như sau:

# Jaccard Similarity between user1 and user2: 

3 / (3 + 0 + 1)
## [1] 0.75
# Jaccard Similarity between user1 and user3:

1 / (1 + 2 + 2)
## [1] 0.2

Diễn giải dưới đây sẽ giúp bạn hiểu một cách trực quan những gì được viết trong các tài liệu Toán về Jaccard Similarity. Ví dụ, với cặp user1 - user2 thì con số 0.75 được đếm bằng a / (a + b + c) trong đó:

  • a lượng giá trị 1 mà cột user1 và user đều có. Trong tình huống của chúng ta thì a = 2.
  • b là số lượng giá trị 1 xuất hiện ở cột user1 nhưng lại là 0 ở cột user2. Trong tình huống của chúng ta thì b = 0.
  • c là số lượng giá trị 1 xuất hiện ở cột user2 nhưng lại là 0 ở cột user1.

Kết quả chỉ ra rằng giữa user1 - user2 là giống nhau hơn so với cặp user1 - user3. Điều này dẫn đến một suy luận là nên khuyến nghị những diễn viên yêu thích của user2 cho user1. Đây chính là cơ sở để xây dựng Recommender Engine dựa trên chỉ số tương đồng Jaccard.

A Real-world Application

Chúng ta sử dụng bộ dữ liệu MovieLens Data Set để xây dựng một Recommender dựa trên Jaccard Similarity. Trước hết chúng ta đọc bộ dữ liệu này và convert về Binary Data (lưu ý là chúng ta có ratings cho các movies nhưng bài toán sẽ được giải quyết trên giả định chúng ta không có ratings và do vậy tất các những movies có rating chúng ta sẽ phải convert về 1):

# 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

# All ratings: 

ratings$rating %>% unique() -> all_ratings

# Set all users and movies:  

all_users <- ratings$userId %>% unique() %>% as.character()

all_movies <- ratings$movieId %>% unique() %>% as.character()

# convert to binary matrix: 

ratings %>% 
  select(-timestamp) %>% 
  spread(key = movieId, value = rating, fill = 0) %>% 
  mutate_at(.vars = all_movies, function(x) {case_when(x %in% all_ratings ~ 1, TRUE ~ 0)}) %>% 
  mutate(userId = all_users) -> df_binary

# Show some observations: 

head(df_binary[1:5, 1:5])
## # A tibble: 5 x 5
##   userId   `1`   `2`   `3`   `4`
##   <chr>  <dbl> <dbl> <dbl> <dbl>
## 1 1          1     0     1     0
## 2 2          0     0     0     0
## 3 3          0     0     0     0
## 4 4          0     0     0     0
## 5 5          1     0     0     0

COnvert df_binary về binary matrix (cho tiện tính toán trên matrix, nếu không thích tính toán với matrix có thể bỏ qua bước này):

# Convert to transpose of df_binary: 

df_binary %>% 
  select(-userId) %>% 
  t() %>% 
  as.matrix() %>% 
  as.data.frame() -> my_mat

# Set col and row names for the transpose: 

colnames(my_mat) <- all_users
rownames(my_mat) <- all_movies

Viết hàm có tên jaccard_sim() tính Jaccard Similarity giữa hai users (không loại trừ hai users này là một):

# Function calculates Jaccard Similarity: 

jaccard_sim <- function(two_users) {
  
  sums <- rowSums(my_mat[, two_users])
  
  a <- length(sums[sums == 2])
  
  a_b_c <- length(sums[sums == 1]) + a
  
  jaccard_sim <- a/a_b_c
  
  return(jaccard_sim)

}

Dưới đây là tương đồng giữa user thứ nhất và thứ hai:

# Test the function: 

jaccard_sim(all_users[c(1, 2)])
## [1] 0.007722008

Đương nhiên user1 và chính nó sẽ có tương đồng là 1:

jaccard_sim(all_users[c(1, 1)])
## [1] 1

Chúng ta sẽ sử dụng hàm này cho các bước xây dựng Recommender Engine sau này. Sử dụng cách tiếp cận đã trình bày trong Part 3 chúng ta sẽ chọn mốc thời gian 2018-01-01 để chia dữ liệu thành bộ train và test:

#==================================================
#    Recommender Engine using Jaccard Similarity
#==================================================

# 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)

# All ratings in train data: 

all_ratings_train <- train_raw_data$rating %>% unique()

# Set all users and movies:  

all_users_train <- train_raw_data$userId %>% unique() %>% as.character()

all_movies_train <- train_raw_data$movieId %>% unique() %>% as.character()

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

# convert to binary matrix for train data: 

train_raw_data %>% 
  select(-timestamp) %>% 
  spread(key = movieId, value = rating, fill = 0) %>% 
  mutate_at(.vars = all_movies_train, function(x) {case_when(x %in% all_ratings_train ~ 1, TRUE ~ 0)}) -> df_binary_train

# Convert to transpose: 

df_binary_train %>% 
  select(-userId) %>% 
  t() %>% 
  as.matrix() %>% 
  as.data.frame() -> my_mat_train

# Set col and row names for the transpose of df_binary_train: 

colnames(my_mat_train) <- all_users_train
rownames(my_mat_train) <- all_movies_train

Viết hàm top_5_jaccard() đưa ra 5 users có Jaccard Similarity (JS) cao nhất với một user chọn trước:

# Function returns 5 users with highest Jaccard similarities: 

top_5_jaccard <- function(user_selected) {
  
  m_users <- length(all_users_train)
  
  jaccard_sim_i_th <- NULL
  
  for (j in 1:m_users) {
    
    sums <- rowSums(my_mat_train[, c(user_selected, all_users_train[j])])
    
    a <- length(sums[sums == 2])
    
    a_b_c <- length(sums[sums == 1]) + a
    
    jaccard_sim <- a/a_b_c
    
    jaccard_sim_i_th <- c(jaccard_sim_i_th, jaccard_sim)
    
  }
  
  df_results <- data.frame(userId = all_users_train, similarity = jaccard_sim_i_th)
  
  df_results %>% 
    mutate(reference = case_when(similarity == 1 ~ "yes", TRUE ~ "no")) %>% 
    top_n(n = 6, wt = similarity) %>% 
    arrange(-similarity) -> df_top5
  
  return(df_top5)
  
}

JS cho tất cả các users mà sẽ có mặt cả trước và sau thời điểm 2018-01-01:

# Common users: 

base::intersect(all_users_train, all_users_test) -> common_users

# For all users: 

lapply(common_users, top_5_jaccard) -> jaccard_for_all

# For the first 3 users: 

jaccard_for_all[1:3]
## [[1]]
##   userId similarity reference
## 1     18  1.0000000       yes
## 2    305  0.2541296        no
## 3    561  0.2509458        no
## 4    573  0.2496025        no
## 5    249  0.2405583        no
## 6     63  0.2385621        no
## 
## [[2]]
##   userId similarity reference
## 1     50  1.0000000       yes
## 2    247  0.1311475        no
## 3    328  0.1194030        no
## 4     18  0.1035599        no
## 5    525  0.1031746        no
## 6    339  0.1011673        no
## 
## [[3]]
##   userId similarity reference
## 1     68  1.0000000       yes
## 2    274  0.3473463        no
## 3    608  0.3436066        no
## 4    480  0.3416068        no
## 5    414  0.3130316        no
## 6    177  0.3066502        no

Viết hàm movies_recommended_for_specific_user() trả về movieId của những bộ phim được khuyến nghị cho một user cho trước:

# Function recommends movies for a specific user: 

movies_recommended_for_specific_user <- function(userID) {
  
  top_5_jaccard(userID) -> df_sim_user
  
  df_sim_user %>% 
    filter(similarity != 1) %>% 
    top_n(n = 1, wt = similarity) %>% 
    pull(userId) %>% 
    as.character() -> user_that_most_sim
  
  test_raw_data %>% 
    mutate(userId = as.character(userId)) %>% 
    filter(userId %in% user_that_most_sim) %>% 
    pull(movieId) %>% 
    unique() %>% 
    as.character() -> movies_recom
  
  return(tibble(userId = userID, movies_recom = movies_recom))
  
}

Chúng ta có thể, ví dụ, sử dụng hàm đã có list ra danh sách các bộ phim mà user thứ nhất nên xem. Lưu ý rằng danh sách các bộ phim đó đến từ các bộ phim đã từng được xem bởi user có JS cao nhất - hay tương đồng cao nhất với user thứ nhất:

movies_recommended_for_specific_user(common_users[1]) %>% head()
## # A tibble: 6 x 2
##   userId movies_recom
##   <chr>  <chr>       
## 1 18     25          
## 2 18     288         
## 3 18     300         
## 4 18     316         
## 5 18     357         
## 6 18     474

Viết hàm return_number_Recommendedmovies_viewed() đếm số bộ phim mà Engine khuyến nghị thực tế sẽ được xem bởi một user cho trước:

# Function test

return_number_Recommendedmovies_viewed <- function(user_i_th) {
  
  test_raw_data %>% 
    filter(userId == user_i_th) %>% 
    pull(movieId) %>%
    unique() -> movies_actual
  
  movies_recommended_for_specific_user(user_i_th) -> df_recom
  
  df_recom %>% 
    pull(movies_recom) -> movies_recom
  
  return(sum(movies_recom %in% movies_actual))
}

Với user, ví dụ, thứ 10 chẳng hạn thì số bộ phim mà người này sẽ xem trong số các bộ phim được Engine khuyến nghị là:

return_number_Recommendedmovies_viewed(common_users[10])
## [1] 3

Chúng ta có thể đo lường hiệu quả vận hành của Recommender Engine bằng các so sánh những movies được hệ thống khuyến nghị với những bộ phim mà users sẽ xem sau thời điểm 2018-01-01:

sum(sapply(common_users, return_number_Recommendedmovies_viewed) != 0) / length(common_users)
## [1] 0.35

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

Summary

Recommender Engine được xây dựng dựa trên dữ liệu có trước ngày 2018-01-01 để đưa ra các bộ phim được khuyến nghị cho các users dựa trên độ tương đồng JS. Chất lượng của Engine được tính toán bằng cách so sánh các bộ phim khuyến nghị bởi Engine và các bộ phim thực tế sẽ được xem bởi các users. Các cách tiếp cận khác cho việc đánh giá Engine các bạn có thể sử dụng các hàm sẵn có của thư viện recommenderlab của Michael Hahsler vốn được trình bày trong nhiều textbook và sẽ không được trình bày ở đây.

LS0tDQp0aXRsZTogJ1JlY29tbWVuZGF0aW9uIFN5c3RlbSAoUGFydCA0KScNCmF1dGhvcjogJ0F1dGhvcjogTmd1eWVuIENoaSBEdW5nJw0Kc3VidGl0bGU6ICJSIE1hY2hpbmUgTGVhcm5pbmcgU2VyaWVzIg0Kb3V0cHV0Og0KICBodG1sX2RvY3VtZW50OiANCiAgICBjb2RlX2Rvd25sb2FkOiB0cnVlDQogICAgIyBjb2RlX2ZvbGRpbmc6IGhpZGUNCiAgICBoaWdobGlnaHQ6IHplbmJ1cm4NCiAgICAjIG51bWJlcl9zZWN0aW9uczogeWVzDQogICAgdGhlbWU6ICJmbGF0bHkiDQogICAgdG9jOiBUUlVFDQogICAgdG9jX2Zsb2F0OiBUUlVFDQotLS0NCg0KYGBge3Igc2V0dXAsaW5jbHVkZT1GQUxTRX0NCmtuaXRyOjpvcHRzX2NodW5rJHNldChlY2hvID0gVFJVRSwgd2FybmluZyA9IEZBTFNFLCBtZXNzYWdlID0gRkFMU0UsIGNhY2hlID0gVFJVRSkNCg0KYGBgDQoNCg0KIyBJbnRyb2R1Y3Rpb24NCg0KVuG7gSBSZWNvbW1lbmRhdGlvbiBTeXN0ZW0gdsOgIHjDonkgZOG7sW5nIFJlY29tbWVuZGVyIHRyb25nIGPDoWMgdMOsbmggaHXhu5FuZyB0aMaw4budbmcgcGjDoXQgc2luaCB0cm9uZyB0aOG7sWMgdOG6vyAoY8OzIHJhdGluZ3MsIGtow7RuZyBjw7MgcmF0aW5ncyBjxaluZyBuaMawIMSRw6FuaCBnacOhIGhp4buHdSBxdeG6oyBj4bunYSBSZWNvbW1lbmRlcikgY8OhYyBi4bqhbiBjw7MgdGjhu4MgdGhhbSBraOG6o28gW1BhcnQgMV0oaHR0cHM6Ly9ycHVicy5jb20vY2hpZHVuZ2t0LzYzNDMwMCksIFtQYXJ0IDJdKGh0dHBzOi8vcnB1YnMuY29tL2NoaWR1bmdrdC82Mzg3NjApIHbDoCBbUGFydCAzXShodHRwczovL3JwdWJzLmNvbS9jaGlkdW5na3QvNjM5NzYwKS4gVHJvbmcgcGjhuqduIG7DoHkgY2jDum5nIHRhIHPhur0geMOieSBk4buxbmcgUmVjb21tZW5kZXIgRW5naW5lIGThu7FhIHRyw6puIEphY2NhcmQgU2ltaWxhcml0eSDDoXAgZOG7pW5nIGNobyBCaW5hcnkgRGF0YSAtIHbhu5FuIGzDoCBt4buZdCB0w6xuaCBodeG7kW5nIHBo4buVIGJp4bq/biBraGkgbcOgIGThu68gbGnhu4d1IHbhu4EgaXRlbXMgbMOgIGtow7RuZyBjw7MgcmF0aW5ncyB0xrDGoW5nIOG7qW5nLiANCg0KIyBBYm91dCBKYWNjYXJkIFNpbWlsYXJpdHkNCg0KWGVtIHjDqXQgbeG7mXQgdsOtIGThu6UgZ2nhuqMgxJHhu4tuaCB24buBIDMgSkFWIEZhbnRzIHbDoCA2IGRp4buFbiB2acOqbiB5w6p1IHRow61jaCBj4bunYSBo4buNIG5oxrAgc2F1OiANCg0KYGBge3J9DQojIENsZWFyIHdvcmtzcGFjZTogDQoNCnJtKGxpc3QgPSBscygpKQ0KDQojPT09PT09PT09PT09PT09PT09PT09PT09PT09PQ0KIyAgICBKYWNjYXJkIFNpbWlsYXJpdHkNCiM9PT09PT09PT09PT09PT09PT09PT09PT09PT09DQoNCiMgQ3JlYXRlIGEgZmFrZSBkYXRhIHRoYXQgZGVzY3JpYmVzIG1vdmllcyB2aWV3ZWQgYnkgMyB1c2VyczogDQoNCnVzZXIxIDwtIHJlcCgwOjEsIGVhY2ggPSAxLCB0aW1lID0gMykNCnVzZXIyIDwtIGMoMCwgcmVwKDEsIDMpLCAwLCAxKQ0KdXNlcjMgPC0gYygxLCAwLCAwLCAwLCAxLCAxKQ0KDQpyb3dfbmFtZXMgPC0gYygiTWFyaWEgT3phd2EiLCANCiAgICAgICAgICAgICAgICJFaW1pIEZ1a2FkYSIsIA0KICAgICAgICAgICAgICAgIk5vem9taSBTYXNha2kiLCANCiAgICAgICAgICAgICAgICJTb3JhIEFvaSIsIA0KICAgICAgICAgICAgICAgIk1pbm9yaSBIYXRzdW5lIiwNCiAgICAgICAgICAgICAgICJSaWEgU2FrdXJhaSIpDQoNCmRmX2Zha2UgPC0gZGF0YS5mcmFtZSh1c2VyMSA9IHVzZXIxLCB1c2VyMiA9IHVzZXIyLCB1c2VyMyA9IHVzZXIzKQ0KDQpyb3cubmFtZXMoZGZfZmFrZSkgPC0gcm93X25hbWVzDQoNCiMgU2hvdyBkYXRhOiANCg0KZGZfZmFrZQ0KDQpgYGANCg0KVuG7m2kgdXNlcjEgdGjDrCBFaW1pIEZ1a2FkYSwgU29yYSBBb2ksIFJpYSBTYWt1cmFpIGzDoCBiYSBkaeG7hW4gdmnDqm4gecOqdSB0aMOtY2ggY+G7p2EgbmfGsOG7nWkgbsOgeSAoxJHDoW5oIHPhu5EgMSkuIEPDoWMgZGnhu4VuIHZpw6puIGtow6FjIMSRw6FuaCBz4buRIDAgbmdoxKlhIGzDoCBjaMawYSB0aMOtY2guIENow7puZyB0YSBjw7MgdGjhu4MgdMOtbmggbeG7qWMgxJHhu5kgdMawxqFuZyDEkeG7k25nIGPhu6dhIHVzZXIxIG7DoHkgduG7m2kgaGFpIHVzZXJzIGPDsm4gbOG6oWkgdGhlbyB0aMaw4bubYyDEkW8gW0phY2NhcmQgU2ltaWxhcml0eV0oaHR0cHM6Ly9lbi53aWtpcGVkaWEub3JnL3dpa2kvSmFjY2FyZF9pbmRleCkgbmjGsCBzYXU6IA0KDQpgYGB7cn0NCg0KIyBKYWNjYXJkIFNpbWlsYXJpdHkgYmV0d2VlbiB1c2VyMSBhbmQgdXNlcjI6IA0KDQozIC8gKDMgKyAwICsgMSkNCg0KIyBKYWNjYXJkIFNpbWlsYXJpdHkgYmV0d2VlbiB1c2VyMSBhbmQgdXNlcjM6DQoNCjEgLyAoMSArIDIgKyAyKQ0KYGBgDQoNCkRp4buFbiBnaeG6o2kgZMaw4bubaSDEkcOieSBz4bq9IGdpw7pwIGLhuqFuIGhp4buDdSBt4buZdCBjw6FjaCB0cuG7sWMgcXVhbiBuaOG7r25nIGfDrCDEkcaw4bujYyB2aeG6v3QgdHJvbmcgY8OhYyB0w6BpIGxp4buHdSBUb8OhbiB24buBIEphY2NhcmQgU2ltaWxhcml0eS4gVsOtIGThu6UsIHbhu5tpIGPhurdwIHVzZXIxIC0gdXNlcjIgdGjDrCBjb24gc+G7kSAwLjc1IMSRxrDhu6NjIMSR4bq/bSBi4bqxbmcgYSAvIChhICsgYiArIGMpIHRyb25nIMSRw7M6IA0KDQotIGEgbMaw4bujbmcgZ2nDoSB0cuG7iyAxIG3DoCBj4buZdCB1c2VyMSB2w6AgdXNlciDEkeG7gXUgY8OzLiBUcm9uZyB0w6xuaCBodeG7kW5nIGPhu6dhIGNow7puZyB0YSB0aMOsIGEgPSAyLiANCi0gYiBsw6Agc+G7kSBsxrDhu6NuZyBnacOhIHRy4buLIDEgeHXhuqV0IGhp4buHbiDhu58gY+G7mXQgdXNlcjEgbmjGsG5nIGzhuqFpIGzDoCAwIOG7nyBj4buZdCB1c2VyMi4gVHJvbmcgdMOsbmggaHXhu5FuZyBj4bunYSBjaMO6bmcgdGEgdGjDrCBiID0gMC4gDQotIGMgbMOgIHPhu5EgbMaw4bujbmcgZ2nDoSB0cuG7iyAxIHh14bqldCBoaeG7h24g4bufIGPhu5l0IHVzZXIyIG5oxrBuZyBs4bqhaSBsw6AgMCDhu58gY+G7mXQgdXNlcjEuDQoNCkvhur90IHF14bqjIGNo4buJIHJhIHLhurFuZyBnaeG7r2EgdXNlcjEgLSB1c2VyMiBsw6AgZ2nhu5FuZyBuaGF1IGjGoW4gc28gduG7m2kgY+G6t3AgdXNlcjEgLSB1c2VyMy4gxJBp4buBdSBuw6B5IGThuqtuIMSR4bq/biBt4buZdCBzdXkgbHXhuq1uIGzDoCBuw6puIGtodXnhur9uIG5naOG7iyBuaOG7r25nIGRp4buFbiB2acOqbiB5w6p1IHRow61jaCBj4bunYSB1c2VyMiBjaG8gdXNlcjEuIMSQw6J5IGNow61uaCBsw6AgY8ahIHPhu58gxJHhu4MgeMOieSBk4buxbmcgUmVjb21tZW5kZXIgRW5naW5lIGThu7FhIHRyw6puIGNo4buJIHPhu5EgdMawxqFuZyDEkeG7k25nIEphY2NhcmQuIA0KDQojIEEgUmVhbC13b3JsZCBBcHBsaWNhdGlvbg0KDQpDaMO6bmcgdGEgc+G7rSBk4bulbmcgYuG7mSBk4buvIGxp4buHdSBbTW92aWVMZW5zIERhdGEgU2V0XShodHRwczovL2dyb3VwbGVucy5vcmcvZGF0YXNldHMvbW92aWVsZW5zL2xhdGVzdC8pIMSR4buDIHjDonkgZOG7sW5nIG3hu5l0IFJlY29tbWVuZGVyIGThu7FhIHRyw6puIEphY2NhcmQgU2ltaWxhcml0eS4gVHLGsOG7m2MgaOG6v3QgY2jDum5nIHRhIMSR4buNYyBi4buZIGThu68gbGnhu4d1IG7DoHkgdsOgIGNvbnZlcnQgduG7gSBCaW5hcnkgRGF0YSAobMawdSDDvSBsw6AgY2jDum5nIHRhIGPDsyByYXRpbmdzIGNobyBjw6FjIG1vdmllcyBuaMawbmcgYsOgaSB0b8OhbiBz4bq9IMSRxrDhu6NjIGdp4bqjaSBxdXnhur90IHRyw6puIGdp4bqjIMSR4buLbmggY2jDum5nIHRhIGtow7RuZyBjw7MgcmF0aW5ncyB2w6AgZG8gduG6rXkgdOG6pXQgY8OhYyBuaOG7r25nIG1vdmllcyBjw7MgcmF0aW5nIGNow7puZyB0YSBz4bq9IHBo4bqjaSBjb252ZXJ0IHbhu4EgMSk6IA0KDQpgYGB7cn0NCiMgSW1wb3J0IGRhdGE6IA0KDQpsaWJyYXJ5KHRpZHl2ZXJzZSkNCg0KcmF0aW5ncyA8LSByZWFkX2NzdigicmF0aW5ncy5jc3YiKQ0KDQojIENvbnZlcnQgdG8gcmVhbCB0aW1lOiANCg0KbGlicmFyeShsdWJyaWRhdGUpDQoNCnJhdGluZ3MgJT4lIA0KICBtdXRhdGUodGltZXN0YW1wID0gYXNfZGF0ZXRpbWUodGltZXN0YW1wKSwgdGltZXN0YW1wID0gZGF0ZSh0aW1lc3RhbXApKSAtPiByYXRpbmdzDQoNCiMgQWxsIHJhdGluZ3M6IA0KDQpyYXRpbmdzJHJhdGluZyAlPiUgdW5pcXVlKCkgLT4gYWxsX3JhdGluZ3MNCg0KIyBTZXQgYWxsIHVzZXJzIGFuZCBtb3ZpZXM6ICANCg0KYWxsX3VzZXJzIDwtIHJhdGluZ3MkdXNlcklkICU+JSB1bmlxdWUoKSAlPiUgYXMuY2hhcmFjdGVyKCkNCg0KYWxsX21vdmllcyA8LSByYXRpbmdzJG1vdmllSWQgJT4lIHVuaXF1ZSgpICU+JSBhcy5jaGFyYWN0ZXIoKQ0KDQojIGNvbnZlcnQgdG8gYmluYXJ5IG1hdHJpeDogDQoNCnJhdGluZ3MgJT4lIA0KICBzZWxlY3QoLXRpbWVzdGFtcCkgJT4lIA0KICBzcHJlYWQoa2V5ID0gbW92aWVJZCwgdmFsdWUgPSByYXRpbmcsIGZpbGwgPSAwKSAlPiUgDQogIG11dGF0ZV9hdCgudmFycyA9IGFsbF9tb3ZpZXMsIGZ1bmN0aW9uKHgpIHtjYXNlX3doZW4oeCAlaW4lIGFsbF9yYXRpbmdzIH4gMSwgVFJVRSB+IDApfSkgJT4lIA0KICBtdXRhdGUodXNlcklkID0gYWxsX3VzZXJzKSAtPiBkZl9iaW5hcnkNCg0KIyBTaG93IHNvbWUgb2JzZXJ2YXRpb25zOiANCg0KaGVhZChkZl9iaW5hcnlbMTo1LCAxOjVdKQ0KDQpgYGANCg0KQ09udmVydCBkZl9iaW5hcnkgduG7gSBiaW5hcnkgbWF0cml4IChjaG8gdGnhu4duIHTDrW5oIHRvw6FuIHRyw6puIG1hdHJpeCwgbuG6v3Uga2jDtG5nIHRow61jaCB0w61uaCB0b8OhbiB24bubaSBtYXRyaXggY8OzIHRo4buDIGLhu48gcXVhIGLGsOG7m2MgbsOgeSk6IA0KDQpgYGB7cn0NCg0KIyBDb252ZXJ0IHRvIHRyYW5zcG9zZSBvZiBkZl9iaW5hcnk6IA0KDQpkZl9iaW5hcnkgJT4lIA0KICBzZWxlY3QoLXVzZXJJZCkgJT4lIA0KICB0KCkgJT4lIA0KICBhcy5tYXRyaXgoKSAlPiUgDQogIGFzLmRhdGEuZnJhbWUoKSAtPiBteV9tYXQNCg0KIyBTZXQgY29sIGFuZCByb3cgbmFtZXMgZm9yIHRoZSB0cmFuc3Bvc2U6IA0KDQpjb2xuYW1lcyhteV9tYXQpIDwtIGFsbF91c2Vycw0Kcm93bmFtZXMobXlfbWF0KSA8LSBhbGxfbW92aWVzDQoNCmBgYA0KDQpWaeG6v3QgaMOgbSBjw7MgdMOqbiBgamFjY2FyZF9zaW0oKWAgdMOtbmggSmFjY2FyZCBTaW1pbGFyaXR5IGdp4buvYSBoYWkgdXNlcnMgKGtow7RuZyBsb+G6oWkgdHLhu6sgaGFpIHVzZXJzIG7DoHkgbMOgIG3hu5l0KTogDQoNCmBgYHtyfQ0KIyBGdW5jdGlvbiBjYWxjdWxhdGVzIEphY2NhcmQgU2ltaWxhcml0eTogDQoNCmphY2NhcmRfc2ltIDwtIGZ1bmN0aW9uKHR3b191c2Vycykgew0KICANCiAgc3VtcyA8LSByb3dTdW1zKG15X21hdFssIHR3b191c2Vyc10pDQogIA0KICBhIDwtIGxlbmd0aChzdW1zW3N1bXMgPT0gMl0pDQogIA0KICBhX2JfYyA8LSBsZW5ndGgoc3Vtc1tzdW1zID09IDFdKSArIGENCiAgDQogIGphY2NhcmRfc2ltIDwtIGEvYV9iX2MNCiAgDQogIHJldHVybihqYWNjYXJkX3NpbSkNCg0KfQ0KYGBgDQoNCkTGsOG7m2kgxJHDonkgbMOgIHTGsMahbmcgxJHhu5NuZyBnaeG7r2EgdXNlciB0aOG7qSBuaOG6pXQgdsOgIHRo4bupIGhhaTogDQoNCmBgYHtyfQ0KIyBUZXN0IHRoZSBmdW5jdGlvbjogDQoNCmphY2NhcmRfc2ltKGFsbF91c2Vyc1tjKDEsIDIpXSkNCg0KYGBgDQoNCsSQxrDGoW5nIG5oacOqbiB1c2VyMSB2w6AgY2jDrW5oIG7DsyBz4bq9IGPDsyB0xrDGoW5nIMSR4buTbmcgbMOgIDE6IA0KDQpgYGB7cn0NCmphY2NhcmRfc2ltKGFsbF91c2Vyc1tjKDEsIDEpXSkNCmBgYA0KDQpDaMO6bmcgdGEgc+G6vSBz4butIGThu6VuZyBow6BtIG7DoHkgY2hvIGPDoWMgYsaw4bubYyB4w6J5IGThu7FuZyBSZWNvbW1lbmRlciBFbmdpbmUgc2F1IG7DoHkuIFPhu60gZOG7pW5nIGPDoWNoIHRp4bq/cCBj4bqtbiDEkcOjIHRyw6xuaCBiw6B5IHRyb25nIFBhcnQgMyBjaMO6bmcgdGEgc+G6vSBjaOG7jW4gbeG7kWMgdGjhu51pIGdpYW4gMjAxOC0wMS0wMSDEkeG7gyBjaGlhIGThu68gbGnhu4d1IHRow6BuaCBi4buZIHRyYWluIHbDoCB0ZXN0OiANCg0KDQpgYGB7cn0NCiM9PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PQ0KIyAgICBSZWNvbW1lbmRlciBFbmdpbmUgdXNpbmcgSmFjY2FyZCBTaW1pbGFyaXR5DQojPT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT0NCg0KIyBTZXQgdGltZSBwb2ludDogDQoNCnRpbWVfc2VsZWN0ZWQgPC0geW1kKCIyMDE4LTAxLTAxIikNCg0KIyBTcGxpdCBkYXRhIGZvciB0cmFpbiBhbmQgdGVzdCBlbmdpbmU6IA0KDQp0cmFpbl9yYXdfZGF0YSA8LSByYXRpbmdzICU+JSBmaWx0ZXIodGltZXN0YW1wIDw9IHRpbWVfc2VsZWN0ZWQpDQoNCnRlc3RfcmF3X2RhdGEgPC0gcmF0aW5ncyAlPiUgZmlsdGVyKHRpbWVzdGFtcCA+IHRpbWVfc2VsZWN0ZWQpDQoNCiMgQWxsIHJhdGluZ3MgaW4gdHJhaW4gZGF0YTogDQoNCmFsbF9yYXRpbmdzX3RyYWluIDwtIHRyYWluX3Jhd19kYXRhJHJhdGluZyAlPiUgdW5pcXVlKCkNCg0KIyBTZXQgYWxsIHVzZXJzIGFuZCBtb3ZpZXM6ICANCg0KYWxsX3VzZXJzX3RyYWluIDwtIHRyYWluX3Jhd19kYXRhJHVzZXJJZCAlPiUgdW5pcXVlKCkgJT4lIGFzLmNoYXJhY3RlcigpDQoNCmFsbF9tb3ZpZXNfdHJhaW4gPC0gdHJhaW5fcmF3X2RhdGEkbW92aWVJZCAlPiUgdW5pcXVlKCkgJT4lIGFzLmNoYXJhY3RlcigpDQoNCmFsbF91c2Vyc190ZXN0IDwtIHRlc3RfcmF3X2RhdGEkdXNlcklkICU+JSB1bmlxdWUoKSAlPiUgYXMuY2hhcmFjdGVyKCkNCg0KIyBjb252ZXJ0IHRvIGJpbmFyeSBtYXRyaXggZm9yIHRyYWluIGRhdGE6IA0KDQp0cmFpbl9yYXdfZGF0YSAlPiUgDQogIHNlbGVjdCgtdGltZXN0YW1wKSAlPiUgDQogIHNwcmVhZChrZXkgPSBtb3ZpZUlkLCB2YWx1ZSA9IHJhdGluZywgZmlsbCA9IDApICU+JSANCiAgbXV0YXRlX2F0KC52YXJzID0gYWxsX21vdmllc190cmFpbiwgZnVuY3Rpb24oeCkge2Nhc2Vfd2hlbih4ICVpbiUgYWxsX3JhdGluZ3NfdHJhaW4gfiAxLCBUUlVFIH4gMCl9KSAtPiBkZl9iaW5hcnlfdHJhaW4NCg0KIyBDb252ZXJ0IHRvIHRyYW5zcG9zZTogDQoNCmRmX2JpbmFyeV90cmFpbiAlPiUgDQogIHNlbGVjdCgtdXNlcklkKSAlPiUgDQogIHQoKSAlPiUgDQogIGFzLm1hdHJpeCgpICU+JSANCiAgYXMuZGF0YS5mcmFtZSgpIC0+IG15X21hdF90cmFpbg0KDQojIFNldCBjb2wgYW5kIHJvdyBuYW1lcyBmb3IgdGhlIHRyYW5zcG9zZSBvZiBkZl9iaW5hcnlfdHJhaW46IA0KDQpjb2xuYW1lcyhteV9tYXRfdHJhaW4pIDwtIGFsbF91c2Vyc190cmFpbg0Kcm93bmFtZXMobXlfbWF0X3RyYWluKSA8LSBhbGxfbW92aWVzX3RyYWluDQpgYGANCg0KVmnhur90IGjDoG0gYHRvcF81X2phY2NhcmQoKWAgxJHGsGEgcmEgNSB1c2VycyBjw7MgSmFjY2FyZCBTaW1pbGFyaXR5IChKUykgY2FvIG5o4bqldCB24bubaSBt4buZdCB1c2VyIGNo4buNbiB0csaw4bubYzogDQoNCmBgYHtyfQ0KDQojIEZ1bmN0aW9uIHJldHVybnMgNSB1c2VycyB3aXRoIGhpZ2hlc3QgSmFjY2FyZCBzaW1pbGFyaXRpZXM6IA0KDQp0b3BfNV9qYWNjYXJkIDwtIGZ1bmN0aW9uKHVzZXJfc2VsZWN0ZWQpIHsNCiAgDQogIG1fdXNlcnMgPC0gbGVuZ3RoKGFsbF91c2Vyc190cmFpbikNCiAgDQogIGphY2NhcmRfc2ltX2lfdGggPC0gTlVMTA0KICANCiAgZm9yIChqIGluIDE6bV91c2Vycykgew0KICAgIA0KICAgIHN1bXMgPC0gcm93U3VtcyhteV9tYXRfdHJhaW5bLCBjKHVzZXJfc2VsZWN0ZWQsIGFsbF91c2Vyc190cmFpbltqXSldKQ0KICAgIA0KICAgIGEgPC0gbGVuZ3RoKHN1bXNbc3VtcyA9PSAyXSkNCiAgICANCiAgICBhX2JfYyA8LSBsZW5ndGgoc3Vtc1tzdW1zID09IDFdKSArIGENCiAgICANCiAgICBqYWNjYXJkX3NpbSA8LSBhL2FfYl9jDQogICAgDQogICAgamFjY2FyZF9zaW1faV90aCA8LSBjKGphY2NhcmRfc2ltX2lfdGgsIGphY2NhcmRfc2ltKQ0KICAgIA0KICB9DQogIA0KICBkZl9yZXN1bHRzIDwtIGRhdGEuZnJhbWUodXNlcklkID0gYWxsX3VzZXJzX3RyYWluLCBzaW1pbGFyaXR5ID0gamFjY2FyZF9zaW1faV90aCkNCiAgDQogIGRmX3Jlc3VsdHMgJT4lIA0KICAgIG11dGF0ZShyZWZlcmVuY2UgPSBjYXNlX3doZW4oc2ltaWxhcml0eSA9PSAxIH4gInllcyIsIFRSVUUgfiAibm8iKSkgJT4lIA0KICAgIHRvcF9uKG4gPSA2LCB3dCA9IHNpbWlsYXJpdHkpICU+JSANCiAgICBhcnJhbmdlKC1zaW1pbGFyaXR5KSAtPiBkZl90b3A1DQogIA0KICByZXR1cm4oZGZfdG9wNSkNCiAgDQp9DQoNCmBgYA0KDQpKUyBjaG8gdOG6pXQgY+G6oyBjw6FjIHVzZXJzIG3DoCBz4bq9IGPDsyBt4bq3dCBj4bqjIHRyxrDhu5tjIHbDoCBzYXUgdGjhu51pIMSRaeG7g20gMjAxOC0wMS0wMTogIA0KDQpgYGB7cn0NCiMgQ29tbW9uIHVzZXJzOiANCg0KYmFzZTo6aW50ZXJzZWN0KGFsbF91c2Vyc190cmFpbiwgYWxsX3VzZXJzX3Rlc3QpIC0+IGNvbW1vbl91c2Vycw0KDQojIEZvciBhbGwgdXNlcnM6IA0KDQpsYXBwbHkoY29tbW9uX3VzZXJzLCB0b3BfNV9qYWNjYXJkKSAtPiBqYWNjYXJkX2Zvcl9hbGwNCg0KIyBGb3IgdGhlIGZpcnN0IDMgdXNlcnM6IA0KDQpqYWNjYXJkX2Zvcl9hbGxbMTozXQ0KDQpgYGANCg0KVmnhur90IGjDoG0gYG1vdmllc19yZWNvbW1lbmRlZF9mb3Jfc3BlY2lmaWNfdXNlcigpYCB0cuG6oyB24buBIG1vdmllSWQgY+G7p2Egbmjhu69uZyBi4buZIHBoaW0gxJHGsOG7o2Mga2h1eeG6v24gbmdo4buLIGNobyBt4buZdCB1c2VyIGNobyB0csaw4bubYzogDQoNCmBgYHtyfQ0KIyBGdW5jdGlvbiByZWNvbW1lbmRzIG1vdmllcyBmb3IgYSBzcGVjaWZpYyB1c2VyOiANCg0KbW92aWVzX3JlY29tbWVuZGVkX2Zvcl9zcGVjaWZpY191c2VyIDwtIGZ1bmN0aW9uKHVzZXJJRCkgew0KICANCiAgdG9wXzVfamFjY2FyZCh1c2VySUQpIC0+IGRmX3NpbV91c2VyDQogIA0KICBkZl9zaW1fdXNlciAlPiUgDQogICAgZmlsdGVyKHNpbWlsYXJpdHkgIT0gMSkgJT4lIA0KICAgIHRvcF9uKG4gPSAxLCB3dCA9IHNpbWlsYXJpdHkpICU+JSANCiAgICBwdWxsKHVzZXJJZCkgJT4lIA0KICAgIGFzLmNoYXJhY3RlcigpIC0+IHVzZXJfdGhhdF9tb3N0X3NpbQ0KICANCiAgdGVzdF9yYXdfZGF0YSAlPiUgDQogICAgbXV0YXRlKHVzZXJJZCA9IGFzLmNoYXJhY3Rlcih1c2VySWQpKSAlPiUgDQogICAgZmlsdGVyKHVzZXJJZCAlaW4lIHVzZXJfdGhhdF9tb3N0X3NpbSkgJT4lIA0KICAgIHB1bGwobW92aWVJZCkgJT4lIA0KICAgIHVuaXF1ZSgpICU+JSANCiAgICBhcy5jaGFyYWN0ZXIoKSAtPiBtb3ZpZXNfcmVjb20NCiAgDQogIHJldHVybih0aWJibGUodXNlcklkID0gdXNlcklELCBtb3ZpZXNfcmVjb20gPSBtb3ZpZXNfcmVjb20pKQ0KICANCn0NCmBgYA0KDQpDaMO6bmcgdGEgY8OzIHRo4buDLCB2w60gZOG7pSwgc+G7rSBk4bulbmcgaMOgbSDEkcOjIGPDsyBsaXN0IHJhIGRhbmggc8OhY2ggY8OhYyBi4buZIHBoaW0gbcOgIHVzZXIgdGjhu6kgbmjhuqV0IG7Dqm4geGVtLiBMxrB1IMO9IHLhurFuZyBkYW5oIHPDoWNoIGPDoWMgYuG7mSBwaGltIMSRw7MgxJHhur9uIHThu6sgY8OhYyBi4buZIHBoaW0gxJHDoyB04burbmcgxJHGsOG7o2MgeGVtIGLhu59pIHVzZXIgY8OzIEpTIGNhbyBuaOG6pXQgLSBoYXkgdMawxqFuZyDEkeG7k25nIGNhbyBuaOG6pXQgduG7m2kgdXNlciB0aOG7qSBuaOG6pXQ6IA0KDQpgYGB7cn0NCm1vdmllc19yZWNvbW1lbmRlZF9mb3Jfc3BlY2lmaWNfdXNlcihjb21tb25fdXNlcnNbMV0pICU+JSBoZWFkKCkNCmBgYA0KDQpWaeG6v3QgaMOgbSBgcmV0dXJuX251bWJlcl9SZWNvbW1lbmRlZG1vdmllc192aWV3ZWQoKWAgxJHhur9tIHPhu5EgYuG7mSBwaGltIG3DoCBFbmdpbmUga2h1eeG6v24gbmdo4buLIHRo4buxYyB04bq/IHPhur0gxJHGsOG7o2MgeGVtIGLhu59pIG3hu5l0IHVzZXIgY2hvIHRyxrDhu5tjOiANCg0KYGBge3J9DQoNCiMgRnVuY3Rpb24gdGVzdA0KDQpyZXR1cm5fbnVtYmVyX1JlY29tbWVuZGVkbW92aWVzX3ZpZXdlZCA8LSBmdW5jdGlvbih1c2VyX2lfdGgpIHsNCiAgDQogIHRlc3RfcmF3X2RhdGEgJT4lIA0KICAgIGZpbHRlcih1c2VySWQgPT0gdXNlcl9pX3RoKSAlPiUgDQogICAgcHVsbChtb3ZpZUlkKSAlPiUNCiAgICB1bmlxdWUoKSAtPiBtb3ZpZXNfYWN0dWFsDQogIA0KICBtb3ZpZXNfcmVjb21tZW5kZWRfZm9yX3NwZWNpZmljX3VzZXIodXNlcl9pX3RoKSAtPiBkZl9yZWNvbQ0KICANCiAgZGZfcmVjb20gJT4lIA0KICAgIHB1bGwobW92aWVzX3JlY29tKSAtPiBtb3ZpZXNfcmVjb20NCiAgDQogIHJldHVybihzdW0obW92aWVzX3JlY29tICVpbiUgbW92aWVzX2FjdHVhbCkpDQp9DQoNCg0KYGBgDQogDQpW4bubaSB1c2VyLCB2w60gZOG7pSwgdGjhu6kgMTAgY2jhurNuZyBo4bqhbiB0aMOsIHPhu5EgYuG7mSBwaGltIG3DoCBuZ8aw4budaSBuw6B5IHPhur0geGVtIHRyb25nIHPhu5EgY8OhYyBi4buZIHBoaW0gxJHGsOG7o2MgRW5naW5lIGtodXnhur9uIG5naOG7iyBsw6A6IA0KDQpgYGB7cn0NCnJldHVybl9udW1iZXJfUmVjb21tZW5kZWRtb3ZpZXNfdmlld2VkKGNvbW1vbl91c2Vyc1sxMF0pDQpgYGANCg0KQ2jDum5nIHRhIGPDsyB0aOG7gyDEkW8gbMaw4budbmcgaGnhu4d1IHF14bqjIHbhuq1uIGjDoG5oIGPhu6dhIFJlY29tbWVuZGVyIEVuZ2luZSBi4bqxbmcgY8OhYyBzbyBzw6FuaCBuaOG7r25nIG1vdmllcyDEkcaw4bujYyBo4buHIHRo4buRbmcga2h1eeG6v24gbmdo4buLIHbhu5tpIG5o4buvbmcgYuG7mSBwaGltIG3DoCB1c2VycyBz4bq9IHhlbSBzYXUgdGjhu51pIMSRaeG7g20gMjAxOC0wMS0wMTogDQoNCmBgYHtyfQ0Kc3VtKHNhcHBseShjb21tb25fdXNlcnMsIHJldHVybl9udW1iZXJfUmVjb21tZW5kZWRtb3ZpZXNfdmlld2VkKSAhPSAwKSAvIGxlbmd0aChjb21tb25fdXNlcnMpDQpgYGANCiANCkvhur90IHF14bqjIG7DoHkgbmdoxKlhIGzDoCAzNSUgc+G7kSB1c2VycyBzYXUgbmfDoHkgMjAxOC0wMS0wMSBz4bq9IHhlbSDDrXQgbmjhuqV0IG3hu5l0IG1vdmllIG3DoCBo4buHIHRo4buRbmcga2h1eeG6v24gbmdo4buLIGNobyBo4buNLg0KDQojIFN1bW1hcnkNCg0KUmVjb21tZW5kZXIgRW5naW5lIMSRxrDhu6NjIHjDonkgZOG7sW5nIGThu7FhIHRyw6puIGThu68gbGnhu4d1IGPDsyB0csaw4bubYyBuZ8OgeSAyMDE4LTAxLTAxIMSR4buDIMSRxrBhIHJhIGPDoWMgYuG7mSBwaGltIMSRxrDhu6NjIGtodXnhur9uIG5naOG7iyBjaG8gY8OhYyB1c2VycyBk4buxYSB0csOqbiDEkeG7mSB0xrDGoW5nIMSR4buTbmcgSlMuIENo4bqldCBsxrDhu6NuZyBj4bunYSBFbmdpbmUgxJHGsOG7o2MgdMOtbmggdG/DoW4gYuG6sW5nIGPDoWNoIHNvIHPDoW5oIGPDoWMgYuG7mSBwaGltIGtodXnhur9uIG5naOG7iyBi4bufaSBFbmdpbmUgdsOgIGPDoWMgYuG7mSBwaGltIHRo4buxYyB04bq/IHPhur0gxJHGsOG7o2MgeGVtIGLhu59pIGPDoWMgdXNlcnMuIEPDoWMgY8OhY2ggdGnhur9wIGPhuq1uIGtow6FjIGNobyB2aeG7h2MgxJHDoW5oIGdpw6EgRW5naW5lIGPDoWMgYuG6oW4gY8OzIHRo4buDIHPhu60gZOG7pW5nIGPDoWMgaMOgbSBz4bq1biBjw7MgY+G7p2EgdGjGsCB2aeG7h24gW3JlY29tbWVuZGVybGFiXShodHRwczovL2NyYW4uci1wcm9qZWN0Lm9yZy93ZWIvcGFja2FnZXMvcmVjb21tZW5kZXJsYWIvaW5kZXguaHRtbCkgY+G7p2EgW01pY2hhZWwgSGFoc2xlcl0oaHR0cHM6Ly9zMi5zbXUuZWR1L0lEQS9yZWNvbW1lbmRlcmxhYi8pIHbhu5FuIMSRxrDhu6NjIHRyw6xuaCBiw6B5IHRyb25nIG5oaeG7gXUgdGV4dGJvb2sgdsOgIHPhur0ga2jDtG5nIMSRxrDhu6NjIHRyw6xuaCBiw6B5IOG7nyDEkcOieS4gDQoNCiMgUmVmZXJlbmNlcw0KDQoxLiBbTWluaW5nIG9mIE1hc3NpdmUgRGF0YXNldHMsIENoYXB0ZXIgM10oaHR0cDovL2luZm9sYWIuc3RhbmZvcmQuZWR1L351bGxtYW4vbW1kcy9jaDMucGRmKQ0KMi4gW0EgU3VydmV5IG9mIEFjY3VyYWN5IEV2YWx1YXRpb24gTWV0cmljcyBvZiBSZWNvbW1lbmRhdGlvbiBUYXNrc10oaHR0cDovL2ptbHIuY3NhaWwubWl0LmVkdS9wYXBlcnMvdm9sdW1lMTAvZ3VuYXdhcmRhbmEwOWEvZ3VuYXdhcmRhbmEwOWEucGRmKS4gDQozLiBbRXZhbHVhdGluZyBSZWNvbW1lbmRhdGlvbiBTeXN0ZW1zXShodHRwOi8vd3d3LmJndS5hYy5pbC9+c2hhbmlndS9QdWJsaWNhdGlvbnMvRXZhbHVhdGlvbk1ldHJpY3MuMTcucGRmKS4gDQo0LiBbRXZhbHVhdGluZyBDb2xsYWJvcmF0aXZlIEZpbHRlcmluZyBSZWNvbW1lbmRlciBTeXN0ZW1zXShodHRwczovL2dyb3VwbGVucy5vcmcvc2l0ZS1jb250ZW50L3VwbG9hZHMvZXZhbHVhdGluZy1UT0lTLTIwMDQxLnBkZikuIA0KNS4gW1JlY29tbWVuZGVyIFN5c3RlbXM6IEFuIEludHJvZHVjdGlvbl0oaHR0cHM6Ly93d3cuYW1hem9uLmNvbS9SZWNvbW1lbmRlci1TeXN0ZW1zLUludHJvZHVjdGlvbi1EaWV0bWFyLUphbm5hY2gvZHAvMDUyMTQ5MzM2Ni9yZWY9cGRfc2JzXzE0Xzc/X2VuY29kaW5nPVVURjgmcGRfcmRfaT0wNTIxNDkzMzY2JnBkX3JkX3I9YzgxZWFjYTctMzUzYS00MzY3LWE5YjEtZmE1ZTc3ZDg0MjJjJnBkX3JkX3c9R3kzT1YmcGRfcmRfd2c9eUJ5V1cmcGZfcmRfcD1iYzA3NDA1MS04MWQxLTQ4NzQtYTNmZC1mZDBjODY3Y2UzYjQmcGZfcmRfcj1TMlJLVDlWV1BYTllDWTlRUEVNVyZwc2M9MSZyZWZSSUQ9UzJSS1Q5VldQWE5ZQ1k5UVBFTVcpLiANCiA=