This project is designed to test your current knowledge on applying word embeddings to the Amazon Fine Foods reviews dataset available through Stanford. This dataset contains 568,454 reviews on 74,258 products.

Your goal is to develop a word embedding model to accurately predict how helpful a review will be. I supply code to help you get the data imported and prepped so that you can focus on the modeling aspect.

Good luck!

Requirements

library(keras)     # provides deep learning procedures
library(tidyverse) # provides basic data wrangling and visualization
library(glue)      # provides efficient print statements
library(testthat)  # provides unit testing

Data importing

The finefoods.txt.gz file has already been downloaded and unzipped for you. All reviews are contained in a single .txt file.

amazon_reviews <- here::here("materials", "data", "amazon-food", "finefoods.txt")
reviews <- read_lines(amazon_reviews)

Each review consists of 8 items and each item is on its own line. The following shows all information collected for the first review.

head(reviews, 8)
[1] "product/productId: B001E4KFG0"                                                                                                                                                                                                                                                       
[2] "review/userId: A3SGXH7AUHU8GW"                                                                                                                                                                                                                                                       
[3] "review/profileName: delmartian"                                                                                                                                                                                                                                                      
[4] "review/helpfulness: 1/1"                                                                                                                                                                                                                                                             
[5] "review/score: 5.0"                                                                                                                                                                                                                                                                   
[6] "review/time: 1303862400"                                                                                                                                                                                                                                                             
[7] "review/summary: Good Quality Dog Food"                                                                                                                                                                                                                                               
[8] "review/text: I have bought several of the Vitality canned dog food products and have found them all to be of good quality. The product looks more like a stew than a processed meat and it smells better. My Labrador is finicky and she appreciates this product better than  most."

Verify we properly imported

Based on the data’s website, we should have the following:

  • Number of reviews: 568,454
  • Number of products: 74,258
  • Number of users: 256,059
review_text <- reviews[str_detect(reviews, "review/text:")]
products <- reviews[str_detect(reviews, "product/productId:")]
users <- reviews[str_detect(reviews, "review/userId:")]

n_reviews <- length(review_text)
n_products <- n_distinct(products)
n_users <- n_distinct(users)

# Verify our imported data aligns with data codebook
expect_equal(n_reviews, 568454)
expect_equal(n_products, 74258)
expect_equal(n_users, 256059)

Extracting key parts of the data

There are two main parts of these reviews that we need for our modeling purpose:

  1. The review text
  2. The fraction of users who found the review helpful

Getting our text

Let’s extract the text

text <- review_text %>%
  str_replace("review/text:", "") %>%
  iconv(to = "UTF-8") %>%
  str_trim()

expect_equal(length(text), n_reviews)

text[1]
[1] "I have bought several of the Vitality canned dog food products and have found them all to be of good quality. The product looks more like a stew than a processed meat and it smells better. My Labrador is finicky and she appreciates this product better than  most."

Getting our labels

Now let’s extract our helpfulness information. This represents the fraction of users who found the review helpful for a given product.

helpfulness_info <- reviews[str_detect(reviews, "review/helpfulness:")] %>%
  str_extract("\\d.*")

expect_equal(length(helpfulness_info), n_reviews)

head(helpfulness_info)
[1] "1/1" "0/0" "1/1" "3/3" "0/0" "0/0"

Let’s separate this information into the number of reviews (denominator) and the number of user who found the review helpful (numerator).

num_reviews <- str_replace(helpfulness_info, "^.*\\/", "") %>% as.integer()
helpfulness <- str_replace(helpfulness_info, "\\/.*$", "") %>% as.integer()

And we’re only going to care about those products with 10+ reviews to try minimize some of the noise.

num_index <- num_reviews >= 10
num_reviews <- num_reviews[num_index]
helpfulness <- helpfulness[num_index]
text <- text[num_index]

# verify that the number of observations in each vector is equal
expect_equal(
  map_int(list(num_reviews, helpfulness, text), length) %>% n_distinct(),
  1
)

glue("There are {sum(num_index)} observations with 10 or more reviews.")
There are 24982 observations with 10 or more reviews.

Our labels are going to be the fraction provided by helpfulness converted to a percentage.

labels <- helpfulness / num_reviews

expect_equal(length(labels), length(text))

range(labels)
[1] 0 1

We can look at a review that is considered very helpful…

first_pos <- first(which(labels == 1))
text[first_pos]
[1] "McCann's Instant Oatmeal is great if you must have your oatmeal but can only scrape together two or three minutes to prepare it. There is no escaping the fact, however, that even the best instant oatmeal is nowhere near as good as even a store brand of oatmeal requiring stovetop preparation.  Still, the McCann's is as good as it gets for instant oatmeal. It's even better than the organic, all-natural brands I have tried.  All the varieties in the McCann's variety pack taste good.  It can be prepared in the microwave or by adding boiling water so it is convenient in the extreme when time is an issue.<br /><br />McCann's use of actual cane sugar instead of high fructose corn syrup helped me decide to buy this product.  Real sugar tastes better and is not as harmful as the other stuff. One thing I do not like, though, is McCann's use of thickeners.  Oats plus water plus heat should make a creamy, tasty oatmeal without the need for guar gum. But this is a convenience product.  Maybe the guar gum is why, after sitting in the bowl a while, the instant McCann's becomes too thick and gluey."

versus a review that is considered very unhelpful.

first_neg <- first(which(labels == 0))
text[first_neg]
[1] "I read the reviews on this and thought id get some for my dog and pup.<br />They will not touch it. Even if I mix it with home cooked food.<br /><br />They like the dollar store dog food better than this.<br /><br />Amazon let me down on even allowing this dog food to be sold under their name."

Let’s get a quick assessment of word usage across the reviews:

text_df <- text %>%
  tibble(.name_repair = ~ "text") %>%
  mutate(text_length = str_trim(text) %>% str_count("\\w+"))

unique_words <- text_df %>%
  tidytext::unnest_tokens(word, text) %>%
  pull(word) %>%
  n_distinct()

avg_review_length <- median(text_df$text_length, na.rm = TRUE)
  
ggplot(text_df, aes(text_length)) +
  geom_histogram(bins = 100, fill = "grey70", color = "grey40") +
  geom_vline(xintercept = avg_review_length, color = "red", lty = "dashed") +
  scale_x_log10() +
  ggtitle(glue("Median review length is {avg_review_length}"),
          subtitle = glue("Total number of unique words is {unique_words}"))

Explore Glove Embeddings

We can explore word embeddings that give us some context of the review language.

# helper functions we'll use to explore word embeddings
source("helper_functions.R")

# clean up text and compute word embeddings
clean_text <- tolower(text) %>%
  str_replace_all(pattern = "[[:punct:] ]+", replacement = " ") %>%
  str_trim()

word_embeddings <- get_embeddings(clean_text)
Creating vocabulary...
Creating term-co-occurence matrix...
Computing embeddings based on GloVe algorithm...
INFO [2019-12-10 11:25:29] 2019-12-10 11:25:29 - epoch 1, expected cost 0.1420
INFO [2019-12-10 11:25:29] 2019-12-10 11:25:29 - epoch 2, expected cost 0.0971
INFO [2019-12-10 11:25:29] 2019-12-10 11:25:29 - epoch 3, expected cost 0.0851
INFO [2019-12-10 11:25:30] 2019-12-10 11:25:30 - epoch 4, expected cost 0.0777
INFO [2019-12-10 11:25:30] 2019-12-10 11:25:30 - epoch 5, expected cost 0.0724
INFO [2019-12-10 11:25:30] 2019-12-10 11:25:30 - epoch 6, expected cost 0.0687
INFO [2019-12-10 11:25:31] 2019-12-10 11:25:31 - epoch 7, expected cost 0.0658
INFO [2019-12-10 11:25:31] 2019-12-10 11:25:31 - epoch 8, expected cost 0.0636
INFO [2019-12-10 11:25:32] 2019-12-10 11:25:32 - epoch 9, expected cost 0.0617
INFO [2019-12-10 11:25:32] 2019-12-10 11:25:32 - epoch 10, expected cost 0.0602
INFO [2019-12-10 11:25:32] 2019-12-10 11:25:32 - epoch 11, expected cost 0.0589
INFO [2019-12-10 11:25:33] 2019-12-10 11:25:33 - epoch 12, expected cost 0.0577
INFO [2019-12-10 11:25:33] 2019-12-10 11:25:33 - epoch 13, expected cost 0.0568
INFO [2019-12-10 11:25:33] 2019-12-10 11:25:33 - epoch 14, expected cost 0.0559
INFO [2019-12-10 11:25:34] 2019-12-10 11:25:34 - epoch 15, expected cost 0.0552
INFO [2019-12-10 11:25:34] 2019-12-10 11:25:34 - epoch 16, expected cost 0.0545
INFO [2019-12-10 11:25:35] 2019-12-10 11:25:35 - epoch 17, expected cost 0.0539
INFO [2019-12-10 11:25:35] 2019-12-10 11:25:35 - epoch 18, expected cost 0.0534
INFO [2019-12-10 11:25:35] 2019-12-10 11:25:35 - epoch 19, expected cost 0.0529
INFO [2019-12-10 11:25:35] Success: early stopping. Improvement at iterartion 19 is less then convergence_tol

Explore your own words!

# find words with similar embeddings
get_similar_words("oil", word_embeddings)
      oil   coconut     olive     honey vegetable 
1.0000000 0.8510423 0.8478722 0.6822951 0.6779796 

Prepare data

Our labels are already a tensor (vector) so we don’t need to do any additional prep.

str(labels)
 num [1:24982] 1 1 1 0.895 0.3 ...

Preprocessing hyperparameters

However, we need to preprocess our text. First, lets decide on two key parameters to use when preprocessing our text:

  1. number of most frequent words used (start with 20000)
  2. the maximum length of our processed text (start with 200)

These are two hyperparameters you can come back to and change as hyperparameters.

top_n_words <- 20000
max_len <- 200

Preprocessing Feature text

Next, you need to create and apply a tokenizer to the text.

tokenizer <- text_tokenizer(num_words = top_n_words) %>% 
  fit_text_tokenizer(text)

names(tokenizer)
 [1] "char_level"                   "document_count"              
 [3] "filters"                      "fit_on_sequences"            
 [5] "fit_on_texts"                 "get_config"                  
 [7] "index_docs"                   "index_word"                  
 [9] "lower"                        "num_words"                   
[11] "oov_token"                    "sequences_to_matrix"         
[13] "sequences_to_texts"           "sequences_to_texts_generator"
[15] "split"                        "texts_to_matrix"             
[17] "texts_to_sequences"           "texts_to_sequences_generator"
[19] "to_json"                      "word_counts"                 
[21] "word_docs"                    "word_index"                  

Now, convert your text to a numerically encoded sequence.

sequences <- texts_to_sequences(tokenizer, text)
# The vectorized first instance:
sequences[[1]]
  [1]  3441   798   716     9    63    29    15   420    20    55   716    17    40
 [14]    62  4852   841   110    26   227   364     5  1665     8    72     9    49
 [27] 15978     1   351   194    13    83     1   111   798   716     9  2730  1126
 [40]    21    41    21    83     4   174   183     7   716  5144  3948  2292   138
 [53]     1  3441     9    21    41    21     8   707    12   798   716    43    83
 [66]   106    57     1   145    35   158   294     2    20   107    35     1   898
 [79]    10     1  3441   427   248    51    41     8    40    27  1450    10     1
 [92]   949    26    67   735  1569    82    25     8     9  1034    10     1  3242
[105]    45    89     9    58   875     6     6  3441    76     7   965  1498   103
[118]   328     7   177   767   259   357  1077    42  1692     5    94    11    31
[131]   200   103   168   106     3     9    18    21  2138    21     1    61   173
[144]    33   196     2    78    18    28   250     9  3441    76     7 12410   930
[157]   484    82   484   618   163    90     4   997   407   716   175     1   207
[170]    12  4660   809    17    11     9     4  1434    31   359     1  4660   809
[183]     9   251    85  1001    10     1   513     4   190     1   798  3441  2123
[196]    93   832     3 14552

Run this code chunk to see how your text has been converted:

cat(crayon::blue("Original text:\n"))
Original text:

text[[1]]
[1] "McCann's Instant Oatmeal is great if you must have your oatmeal but can only scrape together two or three minutes to prepare it. There is no escaping the fact, however, that even the best instant oatmeal is nowhere near as good as even a store brand of oatmeal requiring stovetop preparation.  Still, the McCann's is as good as it gets for instant oatmeal. It's even better than the organic, all-natural brands I have tried.  All the varieties in the McCann's variety pack taste good.  It can be prepared in the microwave or by adding boiling water so it is convenient in the extreme when time is an issue.<br /><br />McCann's use of actual cane sugar instead of high fructose corn syrup helped me decide to buy this product.  Real sugar tastes better and is not as harmful as the other stuff. One thing I do not like, though, is McCann's use of thickeners.  Oats plus water plus heat should make a creamy, tasty oatmeal without the need for guar gum. But this is a convenience product.  Maybe the guar gum is why, after sitting in the bowl a while, the instant McCann's becomes too thick and gluey."
cat(crayon::blue("\nRevised text:\n"))

Revised text:

paste(unlist(tokenizer$index_word)[sequences[[1]]] , collapse = " ")
[1] "mccann's instant oatmeal is great if you must have your oatmeal but can only scrape together two or three minutes to prepare it there is no escaping the fact however that even the best instant oatmeal is nowhere near as good as even a store brand of oatmeal requiring stovetop preparation still the mccann's is as good as it gets for instant oatmeal it's even better than the organic all natural brands i have tried all the varieties in the mccann's variety pack taste good it can be prepared in the microwave or by adding boiling water so it is convenient in the extreme when time is an issue br br mccann's use of actual cane sugar instead of high fructose corn syrup helped me decide to buy this product real sugar tastes better and is not as harmful as the other stuff one thing i do not like though is mccann's use of thickeners oats plus water plus heat should make a creamy tasty oatmeal without the need for guar gum but this is a convenience product maybe the guar gum is why after sitting in the bowl a while the instant mccann's becomes too thick and gluey"

Last, we want to make sure our sequences (aka each processed review) is of equal length.

features <- pad_sequences(sequences, maxlen = max_len)

expect_equal(ncol(features), max_len)

Make sure that the number of observations in your features and labels are equal:

expect_equal(nrow(features), length(labels))

Model training

Before we train our model, let’s go ahead and randomize our review data so that our training and validation data properly represent a mixture of products and users.

set.seed(123)
index <- sample(1:nrow(features))
split_point <- floor(length(index) * .3)
train_index <- index[1:split_point]
valid_index <- index[(split_point + 1):length(index)]

expect_equal(length(train_index) + length(valid_index), length(index))

x_train <- features[train_index, ]
y_train <- labels[train_index]

x_valid <- features[valid_index, ]
y_valid <- labels[valid_index]

Ok, so before we train our model, let’s get an understanding of a baseline loss score that we want to beat. The easiest baseline is to just predict the average of the training label for future observations.

avg <- mean(y_train)
baseline_mse <- mean((y_valid - avg)^2)

cat("Simply predicting the average helpfulness score of", round(avg, 2),
    "for every review would give us a loss score of", round(baseline_mse, 3))
Simply predicting the average helpfulness score of 0.76 for every review would give us a loss score of 0.085

Ok, time to build your model architecture and compile it. Since this is a regression problem but we want to keep our predicted values bounded between 0 and 1, I create a custom “clipped” MSE metric. This is not something I expected you to do but wanted to illustrate here.

model <- keras_model_sequential() %>%
  layer_embedding(input_dim = top_n_words, 
                  output_dim = 32,
                  input_length = max_len) %>% 
  layer_flatten() %>%
  layer_dense(units = 32, activation = "relu") %>%
  layer_dropout(0.5) %>%
  layer_dense(units = 1)

# create metric using backend tensor functions
metric_clipped_mse <- custom_metric("metric_capped_mse", function(y_true, y_pred) {
  y_pred <- k_clip(y_pred, 0, 1)
  k_mean(k_square(y_pred - y_true))
})

model %>% compile(
  optimizer = optimizer_rmsprop(lr = 0.0001),
  loss = metric_clipped_mse,
  metrics = c("mse", "mae")
)

summary(model)
Model: "sequential_1"
____________________________________________________________________________________
Layer (type)                         Output Shape                      Param #      
====================================================================================
embedding_1 (Embedding)              (None, 200, 32)                   640000       
____________________________________________________________________________________
flatten_1 (Flatten)                  (None, 6400)                      0            
____________________________________________________________________________________
dense_2 (Dense)                      (None, 32)                        204832       
____________________________________________________________________________________
dropout_1 (Dropout)                  (None, 32)                        0            
____________________________________________________________________________________
dense_3 (Dense)                      (None, 1)                         33           
====================================================================================
Total params: 844,865
Trainable params: 844,865
Non-trainable params: 0
____________________________________________________________________________________

Let’s train our model:

history <- model %>% fit(
  x_train, y_train,
  epochs = 50,
  batch_size = 128,
  validation_data = list(x_valid, y_valid),
  callbacks = list(
    callback_reduce_lr_on_plateau(patience = 3),
    callback_early_stopping(patience = 10, restore_best_weights = TRUE)
    )
)

In this example we see that our model’s optimal validation loss was 0.057, which is 33% lower than the baseline loss.

opt_mse <- min(history$metrics$val_loss)
glue("Baseline loss score: {round(baseline_mse, 3)}")
Baseline loss score: 0.085
glue("Model loss score: {round(opt_mse, 3)}")
Model loss score: 0.057

The following plots the actual “helpfulness” value for each observation (dots) compared to our model’s predicted value (red line) and the average predicted value (blue). We see that our model is picking up some signal and is a better representation than the average value.

tibble(
  actual = y_valid,
  pred = model %>% predict(x_valid) %>% as.vector()
  ) %>%
  mutate(pred = case_when(
    pred < 0 ~ 0,
    pred > 1 ~ 1,
    TRUE ~ pred
  )) %>%
  arrange(pred) %>%
  mutate(id = row_number()) %>%
  ggplot(aes(x = id)) +
  geom_point(aes(y = actual), size = 1, alpha = 0.2) +
  geom_line(aes(y = pred), color = "red", size = 1) +
  geom_hline(yintercept = avg, lty = "dashed", color = "blue")

How did I choose my parameters

You may be wondering how I chose my hyperparameters (i.e. embedding layer output dimension, number of hidden layers and units, learning rate)? Well, unlike you I had the luxery of time to run a grid search. You can check it out here.

However, realize this is not necessarily an optimized solution so you could continue improve upon it!

🏠

LS0tCnRpdGxlOiAiTkxQOiBUcmFuc2ZlciBsZWFybmluZyBmb3IgQW1hem9uIHJldmlldyB3b3JkIGVtYmVkZGluZ3MiCm91dHB1dDoKICBodG1sX25vdGVib29rOgogICAgdG9jOiB5ZXMKICAgIHRvY19mbG9hdDogdHJ1ZQotLS0KCmBgYHtyIHNldHVwLCBpbmNsdWRlPUZBTFNFfQprbml0cjo6b3B0c19jaHVuayRzZXQoZWNobyA9IFRSVUUsIGNhY2hlID0gRkFMU0UpCmBgYAoKVGhpcyBwcm9qZWN0IGlzIGRlc2lnbmVkIHRvIHRlc3QgeW91ciBjdXJyZW50IGtub3dsZWRnZSBvbiBhcHBseWluZyB3b3JkCmVtYmVkZGluZ3MgdG8gdGhlIFtBbWF6b24gRmluZSBGb29kcyByZXZpZXdzXShodHRwczovL3NuYXAuc3RhbmZvcmQuZWR1L2RhdGEvd2ViLUZpbmVGb29kcy5odG1sKSAKZGF0YXNldCBhdmFpbGFibGUgdGhyb3VnaCBTdGFuZm9yZC4gVGhpcyBkYXRhc2V0IGNvbnRhaW5zIDU2OCw0NTQgcmV2aWV3cyBvbgo3NCwyNTggcHJvZHVjdHMuCgpZb3VyIGdvYWwgaXMgdG8gZGV2ZWxvcCBhIHdvcmQgZW1iZWRkaW5nIG1vZGVsIHRvIGFjY3VyYXRlbHkgcHJlZGljdCBob3cgCl9faGVscGZ1bF9fIGEgcmV2aWV3IHdpbGwgYmUuIEkgc3VwcGx5IGNvZGUgdG8gaGVscCB5b3UgZ2V0IHRoZSBkYXRhCmltcG9ydGVkIGFuZCBwcmVwcGVkIHNvIHRoYXQgeW91IGNhbiBmb2N1cyBvbiB0aGUgbW9kZWxpbmcgYXNwZWN0LgoKX19fR29vZCBsdWNrIV9fXwoKIyBSZXF1aXJlbWVudHMKCmBgYHtyLCBtZXNzYWdlPUZBTFNFLCB3YXJuaW5nPUZBTFNFfQpsaWJyYXJ5KGtlcmFzKSAgICAgIyBwcm92aWRlcyBkZWVwIGxlYXJuaW5nIHByb2NlZHVyZXMKbGlicmFyeSh0aWR5dmVyc2UpICMgcHJvdmlkZXMgYmFzaWMgZGF0YSB3cmFuZ2xpbmcgYW5kIHZpc3VhbGl6YXRpb24KbGlicmFyeShnbHVlKSAgICAgICMgcHJvdmlkZXMgZWZmaWNpZW50IHByaW50IHN0YXRlbWVudHMKbGlicmFyeSh0ZXN0dGhhdCkgICMgcHJvdmlkZXMgdW5pdCB0ZXN0aW5nCmBgYAoKIyBEYXRhIGltcG9ydGluZwoKVGhlIFtmaW5lZm9vZHMudHh0Lmd6XShodHRwczovL3NuYXAuc3RhbmZvcmQuZWR1L2RhdGEvZmluZWZvb2RzLnR4dC5neikgZmlsZSBoYXMKYWxyZWFkeSBiZWVuIGRvd25sb2FkZWQgYW5kIHVuemlwcGVkIGZvciB5b3UuIEFsbCByZXZpZXdzIGFyZSBjb250YWluZWQgaW4gYQpzaW5nbGUgLnR4dCBmaWxlLgoKYGBge3J9CmFtYXpvbl9yZXZpZXdzIDwtIGhlcmU6OmhlcmUoIm1hdGVyaWFscyIsICJkYXRhIiwgImFtYXpvbi1mb29kIiwgImZpbmVmb29kcy50eHQiKQpyZXZpZXdzIDwtIHJlYWRfbGluZXMoYW1hem9uX3Jldmlld3MpCmBgYAoKRWFjaCByZXZpZXcgY29uc2lzdHMgb2YgOCBpdGVtcyBhbmQgZWFjaCBpdGVtIGlzIG9uIGl0cyBvd24gbGluZS4gVGhlIGZvbGxvd2luZwpzaG93cyBhbGwgaW5mb3JtYXRpb24gY29sbGVjdGVkIGZvciB0aGUgZmlyc3QgcmV2aWV3LgoKYGBge3J9CmhlYWQocmV2aWV3cywgOCkKYGBgCgojIFZlcmlmeSB3ZSBwcm9wZXJseSBpbXBvcnRlZAoKQmFzZWQgb24gdGhlIGRhdGEncyBbd2Vic2l0ZV0oaHR0cHM6Ly9zbmFwLnN0YW5mb3JkLmVkdS9kYXRhL3dlYi1GaW5lRm9vZHMuaHRtbCksCndlIHNob3VsZCBoYXZlIHRoZSBmb2xsb3dpbmc6CgotIE51bWJlciBvZiByZXZpZXdzOiA1NjgsNDU0Ci0gTnVtYmVyIG9mIHByb2R1Y3RzOiA3NCwyNTgKLSBOdW1iZXIgb2YgdXNlcnM6IDI1NiwwNTkKCmBgYHtyfQpyZXZpZXdfdGV4dCA8LSByZXZpZXdzW3N0cl9kZXRlY3QocmV2aWV3cywgInJldmlldy90ZXh0OiIpXQpwcm9kdWN0cyA8LSByZXZpZXdzW3N0cl9kZXRlY3QocmV2aWV3cywgInByb2R1Y3QvcHJvZHVjdElkOiIpXQp1c2VycyA8LSByZXZpZXdzW3N0cl9kZXRlY3QocmV2aWV3cywgInJldmlldy91c2VySWQ6IildCgpuX3Jldmlld3MgPC0gbGVuZ3RoKHJldmlld190ZXh0KQpuX3Byb2R1Y3RzIDwtIG5fZGlzdGluY3QocHJvZHVjdHMpCm5fdXNlcnMgPC0gbl9kaXN0aW5jdCh1c2VycykKCiMgVmVyaWZ5IG91ciBpbXBvcnRlZCBkYXRhIGFsaWducyB3aXRoIGRhdGEgY29kZWJvb2sKZXhwZWN0X2VxdWFsKG5fcmV2aWV3cywgNTY4NDU0KQpleHBlY3RfZXF1YWwobl9wcm9kdWN0cywgNzQyNTgpCmV4cGVjdF9lcXVhbChuX3VzZXJzLCAyNTYwNTkpCmBgYAoKCiMgRXh0cmFjdGluZyBrZXkgcGFydHMgb2YgdGhlIGRhdGEKClRoZXJlIGFyZSB0d28gbWFpbiBwYXJ0cyBvZiB0aGVzZSByZXZpZXdzIHRoYXQgd2UgbmVlZCBmb3Igb3VyIG1vZGVsaW5nIHB1cnBvc2U6CgoxLiBUaGUgcmV2aWV3IHRleHQKMi4gVGhlIGZyYWN0aW9uIG9mIHVzZXJzIHdobyBmb3VuZCB0aGUgcmV2aWV3IGhlbHBmdWwKCiMjIEdldHRpbmcgb3VyIHRleHQKCkxldCdzIGV4dHJhY3QgdGhlIHRleHQKCmBgYHtyfQp0ZXh0IDwtIHJldmlld190ZXh0ICU+JQogIHN0cl9yZXBsYWNlKCJyZXZpZXcvdGV4dDoiLCAiIikgJT4lCiAgaWNvbnYodG8gPSAiVVRGLTgiKSAlPiUKICBzdHJfdHJpbSgpCgpleHBlY3RfZXF1YWwobGVuZ3RoKHRleHQpLCBuX3Jldmlld3MpCgp0ZXh0WzFdCmBgYAoKIyMgR2V0dGluZyBvdXIgbGFiZWxzCgpOb3cgbGV0J3MgZXh0cmFjdCBvdXIgaGVscGZ1bG5lc3MgaW5mb3JtYXRpb24uIFRoaXMgcmVwcmVzZW50cyB0aGUgZnJhY3Rpb24gb2YKdXNlcnMgd2hvIGZvdW5kIHRoZSByZXZpZXcgaGVscGZ1bCBmb3IgYSBnaXZlbiBwcm9kdWN0LgoKYGBge3J9CmhlbHBmdWxuZXNzX2luZm8gPC0gcmV2aWV3c1tzdHJfZGV0ZWN0KHJldmlld3MsICJyZXZpZXcvaGVscGZ1bG5lc3M6IildICU+JQogIHN0cl9leHRyYWN0KCJcXGQuKiIpCgpleHBlY3RfZXF1YWwobGVuZ3RoKGhlbHBmdWxuZXNzX2luZm8pLCBuX3Jldmlld3MpCgpoZWFkKGhlbHBmdWxuZXNzX2luZm8pCmBgYAoKTGV0J3Mgc2VwYXJhdGUgdGhpcyBpbmZvcm1hdGlvbiBpbnRvIHRoZSBudW1iZXIgb2YgcmV2aWV3cyAoZGVub21pbmF0b3IpIGFuZAp0aGUgbnVtYmVyIG9mIHVzZXIgd2hvIGZvdW5kIHRoZSByZXZpZXcgaGVscGZ1bCAobnVtZXJhdG9yKS4KCmBgYHtyfQpudW1fcmV2aWV3cyA8LSBzdHJfcmVwbGFjZShoZWxwZnVsbmVzc19pbmZvLCAiXi4qXFwvIiwgIiIpICU+JSBhcy5pbnRlZ2VyKCkKaGVscGZ1bG5lc3MgPC0gc3RyX3JlcGxhY2UoaGVscGZ1bG5lc3NfaW5mbywgIlxcLy4qJCIsICIiKSAlPiUgYXMuaW50ZWdlcigpCmBgYAoKQW5kIHdlJ3JlIG9ubHkgZ29pbmcgdG8gY2FyZSBhYm91dCB0aG9zZSBwcm9kdWN0cyB3aXRoIDEwKyByZXZpZXdzIHRvIHRyeQptaW5pbWl6ZSBzb21lIG9mIHRoZSBub2lzZS4KCmBgYHtyfQpudW1faW5kZXggPC0gbnVtX3Jldmlld3MgPj0gMTAKbnVtX3Jldmlld3MgPC0gbnVtX3Jldmlld3NbbnVtX2luZGV4XQpoZWxwZnVsbmVzcyA8LSBoZWxwZnVsbmVzc1tudW1faW5kZXhdCnRleHQgPC0gdGV4dFtudW1faW5kZXhdCgojIHZlcmlmeSB0aGF0IHRoZSBudW1iZXIgb2Ygb2JzZXJ2YXRpb25zIGluIGVhY2ggdmVjdG9yIGlzIGVxdWFsCmV4cGVjdF9lcXVhbCgKICBtYXBfaW50KGxpc3QobnVtX3Jldmlld3MsIGhlbHBmdWxuZXNzLCB0ZXh0KSwgbGVuZ3RoKSAlPiUgbl9kaXN0aW5jdCgpLAogIDEKKQoKZ2x1ZSgiVGhlcmUgYXJlIHtzdW0obnVtX2luZGV4KX0gb2JzZXJ2YXRpb25zIHdpdGggMTAgb3IgbW9yZSByZXZpZXdzLiIpCmBgYAoKT3VyIGxhYmVscyBhcmUgZ29pbmcgdG8gYmUgdGhlIGZyYWN0aW9uIHByb3ZpZGVkIGJ5IGhlbHBmdWxuZXNzIGNvbnZlcnRlZCB0byBhCnBlcmNlbnRhZ2UuCgpgYGB7cn0KbGFiZWxzIDwtIGhlbHBmdWxuZXNzIC8gbnVtX3Jldmlld3MKCmV4cGVjdF9lcXVhbChsZW5ndGgobGFiZWxzKSwgbGVuZ3RoKHRleHQpKQoKcmFuZ2UobGFiZWxzKQpgYGAKCldlIGNhbiBsb29rIGF0IGEgcmV2aWV3IHRoYXQgaXMgY29uc2lkZXJlZCB2ZXJ5IGhlbHBmdWwuLi4KCmBgYHtyfQpmaXJzdF9wb3MgPC0gZmlyc3Qod2hpY2gobGFiZWxzID09IDEpKQp0ZXh0W2ZpcnN0X3Bvc10KYGBgCgp2ZXJzdXMgYSByZXZpZXcgdGhhdCBpcyBjb25zaWRlcmVkIHZlcnkgdW5oZWxwZnVsLgoKYGBge3J9CmZpcnN0X25lZyA8LSBmaXJzdCh3aGljaChsYWJlbHMgPT0gMCkpCnRleHRbZmlyc3RfbmVnXQpgYGAKCkxldCdzIGdldCBhIHF1aWNrIGFzc2Vzc21lbnQgb2Ygd29yZCB1c2FnZSBhY3Jvc3MgdGhlIHJldmlld3M6CgpgYGB7cn0KdGV4dF9kZiA8LSB0ZXh0ICU+JQogIHRpYmJsZSgubmFtZV9yZXBhaXIgPSB+ICJ0ZXh0IikgJT4lCiAgbXV0YXRlKHRleHRfbGVuZ3RoID0gc3RyX3RyaW0odGV4dCkgJT4lIHN0cl9jb3VudCgiXFx3KyIpKQoKdW5pcXVlX3dvcmRzIDwtIHRleHRfZGYgJT4lCiAgdGlkeXRleHQ6OnVubmVzdF90b2tlbnMod29yZCwgdGV4dCkgJT4lCiAgcHVsbCh3b3JkKSAlPiUKICBuX2Rpc3RpbmN0KCkKCmF2Z19yZXZpZXdfbGVuZ3RoIDwtIG1lZGlhbih0ZXh0X2RmJHRleHRfbGVuZ3RoLCBuYS5ybSA9IFRSVUUpCiAgCmdncGxvdCh0ZXh0X2RmLCBhZXModGV4dF9sZW5ndGgpKSArCiAgZ2VvbV9oaXN0b2dyYW0oYmlucyA9IDEwMCwgZmlsbCA9ICJncmV5NzAiLCBjb2xvciA9ICJncmV5NDAiKSArCiAgZ2VvbV92bGluZSh4aW50ZXJjZXB0ID0gYXZnX3Jldmlld19sZW5ndGgsIGNvbG9yID0gInJlZCIsIGx0eSA9ICJkYXNoZWQiKSArCiAgc2NhbGVfeF9sb2cxMCgpICsKICBnZ3RpdGxlKGdsdWUoIk1lZGlhbiByZXZpZXcgbGVuZ3RoIGlzIHthdmdfcmV2aWV3X2xlbmd0aH0iKSwKICAgICAgICAgIHN1YnRpdGxlID0gZ2x1ZSgiVG90YWwgbnVtYmVyIG9mIHVuaXF1ZSB3b3JkcyBpcyB7dW5pcXVlX3dvcmRzfSIpKQpgYGAKCgojIEV4cGxvcmUgR2xvdmUgRW1iZWRkaW5ncwoKV2UgY2FuIGV4cGxvcmUgd29yZCBlbWJlZGRpbmdzIHRoYXQgZ2l2ZSB1cyBzb21lIGNvbnRleHQgb2YgdGhlIHJldmlldyBsYW5ndWFnZS4KCmBgYHtyfQojIGhlbHBlciBmdW5jdGlvbnMgd2UnbGwgdXNlIHRvIGV4cGxvcmUgd29yZCBlbWJlZGRpbmdzCnNvdXJjZSgiaGVscGVyX2Z1bmN0aW9ucy5SIikKCiMgY2xlYW4gdXAgdGV4dCBhbmQgY29tcHV0ZSB3b3JkIGVtYmVkZGluZ3MKY2xlYW5fdGV4dCA8LSB0b2xvd2VyKHRleHQpICU+JQogIHN0cl9yZXBsYWNlX2FsbChwYXR0ZXJuID0gIltbOnB1bmN0Ol0gXSsiLCByZXBsYWNlbWVudCA9ICIgIikgJT4lCiAgc3RyX3RyaW0oKQoKd29yZF9lbWJlZGRpbmdzIDwtIGdldF9lbWJlZGRpbmdzKGNsZWFuX3RleHQpCmBgYAoKRXhwbG9yZSB5b3VyIG93biB3b3JkcyEKCmBgYHtyfQojIGZpbmQgd29yZHMgd2l0aCBzaW1pbGFyIGVtYmVkZGluZ3MKZ2V0X3NpbWlsYXJfd29yZHMoIm9pbCIsIHdvcmRfZW1iZWRkaW5ncykKYGBgCgojIFByZXBhcmUgZGF0YQoKT3VyIGxhYmVscyBhcmUgYWxyZWFkeSBhIHRlbnNvciAodmVjdG9yKSBzbyB3ZSBkb24ndCBuZWVkIHRvIGRvIGFueSBhZGRpdGlvbmFsCnByZXAuCgpgYGB7cn0Kc3RyKGxhYmVscykKYGBgCgojIyBQcmVwcm9jZXNzaW5nIGh5cGVycGFyYW1ldGVycwoKSG93ZXZlciwgd2UgbmVlZCB0byBwcmVwcm9jZXNzIG91ciB0ZXh0LiBGaXJzdCwgbGV0cyBkZWNpZGUgb24gdHdvIGtleSBwYXJhbWV0ZXJzCnRvIHVzZSB3aGVuIHByZXByb2Nlc3Npbmcgb3VyIHRleHQ6CgoxLiBudW1iZXIgb2YgbW9zdCBmcmVxdWVudCB3b3JkcyB1c2VkIChzdGFydCB3aXRoIDIwMDAwKQoyLiB0aGUgbWF4aW11bSBsZW5ndGggb2Ygb3VyIHByb2Nlc3NlZCB0ZXh0IChzdGFydCB3aXRoIDIwMCkKClRoZXNlIGFyZSB0d28gaHlwZXJwYXJhbWV0ZXJzIHlvdSBjYW4gY29tZSBiYWNrIHRvIGFuZCBjaGFuZ2UgYXMgaHlwZXJwYXJhbWV0ZXJzLgoKYGBge3J9CnRvcF9uX3dvcmRzIDwtIDIwMDAwCm1heF9sZW4gPC0gMjAwCmBgYAoKIyMgUHJlcHJvY2Vzc2luZyBGZWF0dXJlIHRleHQKCk5leHQsIHlvdSBuZWVkIHRvIGNyZWF0ZSBhbmQgYXBwbHkgYSB0b2tlbml6ZXIgdG8gdGhlIHRleHQuCgpgYGB7cn0KdG9rZW5pemVyIDwtIHRleHRfdG9rZW5pemVyKG51bV93b3JkcyA9IHRvcF9uX3dvcmRzKSAlPiUgCiAgZml0X3RleHRfdG9rZW5pemVyKHRleHQpCgpuYW1lcyh0b2tlbml6ZXIpCmBgYAoKTm93LCBjb252ZXJ0IHlvdXIgdGV4dCB0byBhIG51bWVyaWNhbGx5IGVuY29kZWQgc2VxdWVuY2UuCgpgYGB7cn0Kc2VxdWVuY2VzIDwtIHRleHRzX3RvX3NlcXVlbmNlcyh0b2tlbml6ZXIsIHRleHQpCmBgYAoKYGBge3J9CiMgVGhlIHZlY3Rvcml6ZWQgZmlyc3QgaW5zdGFuY2U6CnNlcXVlbmNlc1tbMV1dCmBgYAoKUnVuIHRoaXMgY29kZSBjaHVuayB0byBzZWUgaG93IHlvdXIgdGV4dCBoYXMgYmVlbiBjb252ZXJ0ZWQ6CgpgYGB7cn0gCmNhdChjcmF5b246OmJsdWUoIk9yaWdpbmFsIHRleHQ6XG4iKSkKdGV4dFtbMV1dCgpjYXQoY3JheW9uOjpibHVlKCJcblJldmlzZWQgdGV4dDpcbiIpKQpwYXN0ZSh1bmxpc3QodG9rZW5pemVyJGluZGV4X3dvcmQpW3NlcXVlbmNlc1tbMV1dXSAsIGNvbGxhcHNlID0gIiAiKQpgYGAKCkxhc3QsIHdlIHdhbnQgdG8gbWFrZSBzdXJlIG91ciBzZXF1ZW5jZXMgKGFrYSBlYWNoIHByb2Nlc3NlZCByZXZpZXcpIGlzIG9mIGVxdWFsCmxlbmd0aC4KCmBgYHtyfQpmZWF0dXJlcyA8LSBwYWRfc2VxdWVuY2VzKHNlcXVlbmNlcywgbWF4bGVuID0gbWF4X2xlbikKCmV4cGVjdF9lcXVhbChuY29sKGZlYXR1cmVzKSwgbWF4X2xlbikKYGBgCgpNYWtlIHN1cmUgdGhhdCB0aGUgbnVtYmVyIG9mIG9ic2VydmF0aW9ucyBpbiB5b3VyIGZlYXR1cmVzIGFuZCBsYWJlbHMgYXJlIGVxdWFsOgoKYGBge3J9CmV4cGVjdF9lcXVhbChucm93KGZlYXR1cmVzKSwgbGVuZ3RoKGxhYmVscykpCmBgYAoKCiMgTW9kZWwgdHJhaW5pbmcKCkJlZm9yZSB3ZSB0cmFpbiBvdXIgbW9kZWwsIGxldCdzIGdvIGFoZWFkIGFuZCByYW5kb21pemUgb3VyIHJldmlldyBkYXRhIHNvIHRoYXQKb3VyIHRyYWluaW5nIGFuZCB2YWxpZGF0aW9uIGRhdGEgcHJvcGVybHkgcmVwcmVzZW50IGEgbWl4dHVyZSBvZiBwcm9kdWN0cyBhbmQKdXNlcnMuCgpgYGB7cn0Kc2V0LnNlZWQoMTIzKQppbmRleCA8LSBzYW1wbGUoMTpucm93KGZlYXR1cmVzKSkKc3BsaXRfcG9pbnQgPC0gZmxvb3IobGVuZ3RoKGluZGV4KSAqIC4zKQp0cmFpbl9pbmRleCA8LSBpbmRleFsxOnNwbGl0X3BvaW50XQp2YWxpZF9pbmRleCA8LSBpbmRleFsoc3BsaXRfcG9pbnQgKyAxKTpsZW5ndGgoaW5kZXgpXQoKZXhwZWN0X2VxdWFsKGxlbmd0aCh0cmFpbl9pbmRleCkgKyBsZW5ndGgodmFsaWRfaW5kZXgpLCBsZW5ndGgoaW5kZXgpKQoKeF90cmFpbiA8LSBmZWF0dXJlc1t0cmFpbl9pbmRleCwgXQp5X3RyYWluIDwtIGxhYmVsc1t0cmFpbl9pbmRleF0KCnhfdmFsaWQgPC0gZmVhdHVyZXNbdmFsaWRfaW5kZXgsIF0KeV92YWxpZCA8LSBsYWJlbHNbdmFsaWRfaW5kZXhdCmBgYAoKT2ssIHNvIGJlZm9yZSB3ZSB0cmFpbiBvdXIgbW9kZWwsIGxldCdzIGdldCBhbiB1bmRlcnN0YW5kaW5nIG9mIGEgYmFzZWxpbmUKbG9zcyBzY29yZSB0aGF0IHdlIHdhbnQgdG8gYmVhdC4gVGhlIGVhc2llc3QgYmFzZWxpbmUgaXMgdG8ganVzdCBwcmVkaWN0IHRoZQphdmVyYWdlIG9mIHRoZSB0cmFpbmluZyBsYWJlbCBmb3IgZnV0dXJlIG9ic2VydmF0aW9ucy4KCmBgYHtyfQphdmcgPC0gbWVhbih5X3RyYWluKQpiYXNlbGluZV9tc2UgPC0gbWVhbigoeV92YWxpZCAtIGF2ZyleMikKCmNhdCgiU2ltcGx5IHByZWRpY3RpbmcgdGhlIGF2ZXJhZ2UgaGVscGZ1bG5lc3Mgc2NvcmUgb2YiLCByb3VuZChhdmcsIDIpLAogICAgImZvciBldmVyeSByZXZpZXcgd291bGQgZ2l2ZSB1cyBhIGxvc3Mgc2NvcmUgb2YiLCByb3VuZChiYXNlbGluZV9tc2UsIDMpKQpgYGAKCk9rLCB0aW1lIHRvIGJ1aWxkIHlvdXIgbW9kZWwgYXJjaGl0ZWN0dXJlIGFuZCBjb21waWxlIGl0LiBTaW5jZSB0aGlzIGlzIGEKcmVncmVzc2lvbiBwcm9ibGVtIGJ1dCB3ZSB3YW50IHRvIGtlZXAgb3VyIHByZWRpY3RlZCB2YWx1ZXMgYm91bmRlZCBiZXR3ZWVuIDAKYW5kIDEsIEkgY3JlYXRlIGEgY3VzdG9tICJjbGlwcGVkIiBNU0UgbWV0cmljLiBUaGlzIGlzIG5vdCBzb21ldGhpbmcgSSBleHBlY3RlZAp5b3UgdG8gZG8gYnV0IHdhbnRlZCB0byBpbGx1c3RyYXRlIGhlcmUuCgpgYGB7cn0KbW9kZWwgPC0ga2VyYXNfbW9kZWxfc2VxdWVudGlhbCgpICU+JQogIGxheWVyX2VtYmVkZGluZyhpbnB1dF9kaW0gPSB0b3Bfbl93b3JkcywgCiAgICAgICAgICAgICAgICAgIG91dHB1dF9kaW0gPSAzMiwKICAgICAgICAgICAgICAgICAgaW5wdXRfbGVuZ3RoID0gbWF4X2xlbikgJT4lIAogIGxheWVyX2ZsYXR0ZW4oKSAlPiUKICBsYXllcl9kZW5zZSh1bml0cyA9IDMyLCBhY3RpdmF0aW9uID0gInJlbHUiKSAlPiUKICBsYXllcl9kcm9wb3V0KDAuNSkgJT4lCiAgbGF5ZXJfZGVuc2UodW5pdHMgPSAxKQoKIyBjcmVhdGUgbWV0cmljIHVzaW5nIGJhY2tlbmQgdGVuc29yIGZ1bmN0aW9ucwptZXRyaWNfY2xpcHBlZF9tc2UgPC0gY3VzdG9tX21ldHJpYygibWV0cmljX2NhcHBlZF9tc2UiLCBmdW5jdGlvbih5X3RydWUsIHlfcHJlZCkgewogIHlfcHJlZCA8LSBrX2NsaXAoeV9wcmVkLCAwLCAxKQogIGtfbWVhbihrX3NxdWFyZSh5X3ByZWQgLSB5X3RydWUpKQp9KQoKbW9kZWwgJT4lIGNvbXBpbGUoCiAgb3B0aW1pemVyID0gb3B0aW1pemVyX3Jtc3Byb3AobHIgPSAwLjAwMDEpLAogIGxvc3MgPSBtZXRyaWNfY2xpcHBlZF9tc2UsCiAgbWV0cmljcyA9IGMoIm1zZSIsICJtYWUiKQopCgpzdW1tYXJ5KG1vZGVsKQpgYGAKCkxldCdzIHRyYWluIG91ciBtb2RlbDoKCmBgYHtyfQpoaXN0b3J5IDwtIG1vZGVsICU+JSBmaXQoCiAgeF90cmFpbiwgeV90cmFpbiwKICBlcG9jaHMgPSA1MCwKICBiYXRjaF9zaXplID0gMTI4LAogIHZhbGlkYXRpb25fZGF0YSA9IGxpc3QoeF92YWxpZCwgeV92YWxpZCksCiAgY2FsbGJhY2tzID0gbGlzdCgKICAgIGNhbGxiYWNrX3JlZHVjZV9scl9vbl9wbGF0ZWF1KHBhdGllbmNlID0gMyksCiAgICBjYWxsYmFja19lYXJseV9zdG9wcGluZyhwYXRpZW5jZSA9IDEwLCByZXN0b3JlX2Jlc3Rfd2VpZ2h0cyA9IFRSVUUpCiAgICApCikKYGBgCgpJbiB0aGlzIGV4YW1wbGUgd2Ugc2VlIHRoYXQgb3VyIG1vZGVsJ3Mgb3B0aW1hbCB2YWxpZGF0aW9uIGxvc3Mgd2FzIDAuMDU3LCB3aGljaAppcyAzMyUgbG93ZXIgdGhhbiB0aGUgYmFzZWxpbmUgbG9zcy4gCgpgYGB7cn0Kb3B0X21zZSA8LSBtaW4oaGlzdG9yeSRtZXRyaWNzJHZhbF9sb3NzKQpnbHVlKCJCYXNlbGluZSBsb3NzIHNjb3JlOiB7cm91bmQoYmFzZWxpbmVfbXNlLCAzKX0iKQpnbHVlKCJNb2RlbCBsb3NzIHNjb3JlOiB7cm91bmQob3B0X21zZSwgMyl9IikKYGBgCgpUaGUgZm9sbG93aW5nIHBsb3RzIHRoZSBhY3R1YWwgImhlbHBmdWxuZXNzIiB2YWx1ZSBmb3IgZWFjaCBvYnNlcnZhdGlvbiAoZG90cykKY29tcGFyZWQgdG8gb3VyIG1vZGVsJ3MgcHJlZGljdGVkIHZhbHVlIChyZWQgbGluZSkgYW5kIHRoZSBhdmVyYWdlIHByZWRpY3RlZAp2YWx1ZSAoYmx1ZSkuIFdlIHNlZSB0aGF0IG91ciBtb2RlbCBpcyBwaWNraW5nIHVwIHNvbWUgc2lnbmFsIGFuZCBpcyBhIGJldHRlcgpyZXByZXNlbnRhdGlvbiB0aGFuIHRoZSBhdmVyYWdlIHZhbHVlLgoKYGBge3J9CnRpYmJsZSgKICBhY3R1YWwgPSB5X3ZhbGlkLAogIHByZWQgPSBtb2RlbCAlPiUgcHJlZGljdCh4X3ZhbGlkKSAlPiUgYXMudmVjdG9yKCkKICApICU+JQogIG11dGF0ZShwcmVkID0gY2FzZV93aGVuKAogICAgcHJlZCA8IDAgfiAwLAogICAgcHJlZCA+IDEgfiAxLAogICAgVFJVRSB+IHByZWQKICApKSAlPiUKICBhcnJhbmdlKHByZWQpICU+JQogIG11dGF0ZShpZCA9IHJvd19udW1iZXIoKSkgJT4lCiAgZ2dwbG90KGFlcyh4ID0gaWQpKSArCiAgZ2VvbV9wb2ludChhZXMoeSA9IGFjdHVhbCksIHNpemUgPSAxLCBhbHBoYSA9IDAuMikgKwogIGdlb21fbGluZShhZXMoeSA9IHByZWQpLCBjb2xvciA9ICJyZWQiLCBzaXplID0gMSkgKwogIGdlb21faGxpbmUoeWludGVyY2VwdCA9IGF2ZywgbHR5ID0gImRhc2hlZCIsIGNvbG9yID0gImJsdWUiKQpgYGAKCiMgSG93IGRpZCBJIGNob29zZSBteSBwYXJhbWV0ZXJzCgpZb3UgbWF5IGJlIHdvbmRlcmluZyBob3cgSSBjaG9zZSBteSBoeXBlcnBhcmFtZXRlcnMgKGkuZS4gZW1iZWRkaW5nIGxheWVyIG91dHB1dApkaW1lbnNpb24sIG51bWJlciBvZiBoaWRkZW4gbGF5ZXJzIGFuZCB1bml0cywgbGVhcm5pbmcgcmF0ZSk/IFdlbGwsIHVubGlrZSB5b3UKSSBoYWQgdGhlIGx1eGVyeSBvZiB0aW1lIHRvIHJ1biBhIGdyaWQgc2VhcmNoLiBZb3UgY2FuIGNoZWNrIGl0IG91dApbaGVyZV0oaHR0cHM6Ly9yc3R1ZGlvLWNvbmYtMjAyMC5naXRodWIuaW8vZGwta2VyYXMtdGYvbm90ZWJvb2tzL2FtYXpvbi1lbWJlZGRpbmdzLWdyaWQtc2VhcmNoLm5iLmh0bWwpLiAKCkhvd2V2ZXIsIHJlYWxpemUgdGhpcyBpcyBub3QgbmVjZXNzYXJpbHkgYW4gb3B0aW1pemVkIHNvbHV0aW9uIHNvIHlvdSBjb3VsZApjb250aW51ZSBpbXByb3ZlIHVwb24gaXQhCgpb8J+PoF0oaHR0cHM6Ly9naXRodWIuY29tL3JzdHVkaW8tY29uZi0yMDIwL2RsLWtlcmFzLXRmKQ==