Add HistogramSets to the catapult tool

Change-Id: I44fb46481f7afc13e9a4e63d7877c1577c22d50d
diff --git a/catapult/cmd/catapult/add_diagnostics.go b/catapult/cmd/catapult/add_diagnostics.go
deleted file mode 100644
index c59519c..0000000
--- a/catapult/cmd/catapult/add_diagnostics.go
+++ /dev/null
@@ -1,34 +0,0 @@
-// Copyright 2017 The Fuchsia Authors. All rights reserved.
-// Use of this source code is governed by a BSD-style license that can be
-// found in the LICENSE file
-
-package main
-
-import (
-	"context"
-	"flag"
-	"log"
-
-	"github.com/google/subcommands"
-)
-
-type AddDiagnosticsCommand struct{}
-
-func (*AddDiagnosticsCommand) Name() string {
-	return "add_diagnostics"
-}
-
-func (*AddDiagnosticsCommand) Usage() string {
-	return "add_diagnostics"
-}
-
-func (*AddDiagnosticsCommand) Synopsis() string {
-	return "Adds diagnostics to a HistogramSet"
-}
-
-func (*AddDiagnosticsCommand) SetFlags(flags *flag.FlagSet) {}
-
-func (*AddDiagnosticsCommand) Execute(ctx context.Context, f *flag.FlagSet, _ ...interface{}) subcommands.ExitStatus {
-	log.Println("Unimplemented: AddDiagnostics")
-	return subcommands.ExitFailure
-}
diff --git a/catapult/diagnostic.go b/catapult/diagnostic.go
new file mode 100644
index 0000000..c30103f
--- /dev/null
+++ b/catapult/diagnostic.go
@@ -0,0 +1,31 @@
+// Copyright 2018 The Fuchsia Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file
+
+package catapult
+
+// Types of Catapult Diagnostics.
+//
+// Every Diagnostic must have a Type value from this list.  For a list of all
+// Diagnostic types, see:
+// https://github.com/catapult-project/catapult/blob/master/docs/histogram-set-json-format.md#diagnostics
+const (
+	DiagnosticTypeGenericSet = "GenericSet"
+)
+
+// Diagnostic is an interface for Catapult Diagnostics.
+type Diagnostic interface {
+	// GetGUID returns the GUID of this diagnostic.
+	GetGUID() string
+}
+
+// GenericSetDiagnostic stores arbitary untyped data in Histograms.
+type GenericSetDiagnostic struct {
+	Type   string   `json:"type"`
+	GUID   string   `json:"guid"`
+	Values []string `json:"values"`
+}
+
+func (d *GenericSetDiagnostic) GetGUID() string {
+	return d.GUID
+}
diff --git a/catapult/histogram.go b/catapult/histogram.go
index f5719e0..5e82509 100644
--- a/catapult/histogram.go
+++ b/catapult/histogram.go
@@ -1,4 +1,4 @@
-// Copyright 2017 The Fuchsia Authors. All rights reserved.
+// Copyright 2018 The Fuchsia Authors. All rights reserved.
 // Use of this source code is governed by a BSD-style license that can be
 // found in the LICENSE file
 
@@ -6,6 +6,7 @@
 
 import (
 	"fmt"
+	"log"
 	"math"
 
 	schema "fuchsia.googlesource.com/testing/perf/schema/v1"
@@ -25,27 +26,50 @@
 //   AllBins
 //   SummaryOptions
 type Histogram struct {
-	Name               string            `json:"name"`
-	GUID               string            `json:"guid"`
-	Unit               string            `json:"unit"`
-	Description        string            `json:"description"`
-	Diagnostics        map[string]string `json:"diagnostics"`
-	SampleValues       []float64         `json:"sampleValues"`
-	MaxNumSampleValues int               `json:"maxNumSampleValues"`
-	NumNans            int               `json:"numNans"`
-	Running            []float64         `json:"running"`
+	Name               string    `json:"name"`
+	GUID               string    `json:"guid"`
+	Unit               string    `json:"unit"`
+	Description        string    `json:"description"`
+	SampleValues       []float64 `json:"sampleValues"`
+	MaxNumSampleValues int       `json:"maxNumSampleValues"`
+	NumNans            int       `json:"numNans"`
+	Running            []float64 `json:"running"`
+	// Diagnostics maps a Diagnostic's name to its GUID.
+	//
+	// These map entries communicate that the diagnostic with the given
+	// name and GUID contains metadata that can help debug regressions and
+	// other issues with this Histogram in the Catapult Dashboard.
+	Diagnostics map[string]string `json:"diagnostics"`
 }
 
-// ConvertVariantsToHistograms converts a collection of Fuchsia benchmark variants into
-// a list of Catapult Histograms.
-func ConvertVariantsToHistograms(variants []schema.Variant) []Histogram {
-	var histogramSet []Histogram
+// AddDiagnostic associates name with the given GUID in this Histogram's
+// Diagnostics map.
+//
+// If the the new entry overwrites an existing entry, a warning is logged.
+func (h *Histogram) AddDiagnostic(name string, guid string) {
+
+	if h.Diagnostics == nil {
+		h.Diagnostics = make(map[string]string)
+	}
+
+	if existing, ok := h.Diagnostics[name]; ok && existing != guid {
+		log.Printf(
+			"Overwriting shared Diagnostic %v in Histogram %v."+
+				"($old, $new) = (%v, %v)",
+			name, h.Name, existing, guid)
+	}
+
+	h.Diagnostics[name] = guid
+}
+
+// ConvertVariantsToHistograms converts a collection of Fuchsia benchmark
+// variants into a list of Catapult Histograms.
+func ConvertVariantsToHistograms(variants []schema.Variant) HistogramSet {
+	histogramSet := HistogramSet{}
 	for _, variant := range variants {
 		for _, benchmarkData := range variant.FBenchmarksData {
-			histogramSet = append(
-				histogramSet,
-				createHistogram(variant, benchmarkData),
-			)
+			histogramSet.AddHistogram(
+				createHistogram(variant, benchmarkData))
 		}
 	}
 
diff --git a/catapult/histogram_set.go b/catapult/histogram_set.go
new file mode 100644
index 0000000..1a6f2c6
--- /dev/null
+++ b/catapult/histogram_set.go
@@ -0,0 +1,62 @@
+// Copyright 2018 The Fuchsia Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file
+
+package catapult
+
+import (
+	"errors"
+)
+
+// HistogramSet is a collection of Histograms and related Diagnostics.
+type HistogramSet struct {
+	histograms  []Histogram
+	diagnostics []Diagnostic
+}
+
+// AddHistogram adds a histogram to this HistogramSet.
+//
+// If this HistogramSet already contains a Diagnostic, the addition fails and
+// an error is returned.
+//
+// TODO(kharland): This API does not allow adding a Histogram after Diagnostics
+// have been added because it is not clear if the caller intended for the new
+// Histogram to contain the same references to the shared Diagnostics in this
+// HistogramSet as the existing Histograms. Allow adding Histograms after
+// Diagnostics when Fuchsia needs this functionality.
+func (h *HistogramSet) AddHistogram(histogram Histogram) error {
+	if len(h.diagnostics) > 0 {
+		return errors.New("can't add Histogram after adding Diagnostics")
+	}
+	h.histograms = append(h.histograms, histogram)
+	return nil
+}
+
+// AddSharedDiagnostic adds a diagnostic to this HistogramSet.
+//
+// A shared Diagnostic is a diagnostic that exists in the HistogramSet, so
+// that it may be referenced by Histograms within the set. A reference to the
+// Diagnostic is automatically added to each Histogram in the set when calling
+// this method. name is used as the key for this reference.
+func (h *HistogramSet) AddSharedDiagnostic(name string, diagnostic Diagnostic) {
+	h.diagnostics = append(h.diagnostics, diagnostic)
+
+	guid := diagnostic.GetGUID()
+
+	// Reference the diagnostic's GUID in each Histogram in this set.
+	for _, histogram := range h.histograms {
+		histogram.AddDiagnostic(name, guid)
+	}
+}
+
+// ToJSON implements the HistogramSet interface.
+func (h *HistogramSet) ToJSON() []interface{} {
+	jsonObject := []interface{}{}
+	for _, entry := range h.histograms {
+		jsonObject = append(jsonObject, entry)
+	}
+	for _, entry := range h.diagnostics {
+		jsonObject = append(jsonObject, entry)
+	}
+	return jsonObject
+}
diff --git a/catapult/histogram_set_test.go b/catapult/histogram_set_test.go
new file mode 100644
index 0000000..aa93e5a
--- /dev/null
+++ b/catapult/histogram_set_test.go
@@ -0,0 +1,134 @@
+// Copyright 2018 The Fuchsia Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file
+package catapult_test
+
+import (
+	"reflect"
+	"testing"
+
+	"fuchsia.googlesource.com/testing/catapult"
+	"github.com/kr/pretty"
+)
+
+func TestAddHistogram(t *testing.T) {
+	t.Run("When the HistogramSet is empty", func(t *testing.T) {
+		histogramSet := catapult.HistogramSet{}
+		histogram := catapult.Histogram{Name: "test_histogram"}
+
+		expectedJSON := []interface{}{histogram}
+		histogramSet.AddHistogram(histogram)
+
+		if !reflect.DeepEqual(histogramSet.ToJSON(), expectedJSON) {
+			t.Errorf("Invalid HistogramSet. Got %+v. Expected %+v",
+				pretty.Sprint(histogramSet.ToJSON()),
+				pretty.Sprint(expectedJSON),
+			)
+		}
+	})
+
+	t.Run("When the HistogramSet contains other Histograms", func(t *testing.T) {
+		histogramSet := catapult.HistogramSet{}
+		histogram1 := catapult.Histogram{Name: "test_histogram_1"}
+		histogram2 := catapult.Histogram{Name: "test_histogram_2"}
+
+		expectedJSON := []interface{}{histogram1, histogram2}
+
+		histogramSet.AddHistogram(histogram1)
+		histogramSet.AddHistogram(histogram2)
+		if !reflect.DeepEqual(histogramSet.ToJSON(), expectedJSON) {
+			t.Errorf("Invalid HistogramSet. Got %+v. Expected %+v",
+				pretty.Sprint(histogramSet.ToJSON()),
+				pretty.Sprint(expectedJSON),
+			)
+		}
+	})
+
+	t.Run("When the HistogramSet contains other Diagnostics", func(t *testing.T) {
+		histogramSet := catapult.HistogramSet{}
+		histogram := catapult.Histogram{
+			Name:        "test_histogram",
+			Diagnostics: make(map[string]string),
+		}
+		diagnostic := &catapult.GenericSetDiagnostic{
+			Type:   catapult.DiagnosticTypeGenericSet,
+			Values: []string{"test_value"},
+			GUID:   "test_guid",
+		}
+
+		// Should return an error without modifying the HistogramSet
+		// because all Histograms must be added before any Diagnotics
+		// are added.
+		expectedJSON := []interface{}{diagnostic}
+		histogramSet.AddSharedDiagnostic("test_diagnostic", diagnostic)
+		if histogramSet.AddHistogram(histogram) == nil {
+			t.Errorf("expected an error when adding a Histogram after a Diagnostic")
+		}
+
+		if !reflect.DeepEqual(histogramSet.ToJSON(), expectedJSON) {
+			t.Errorf("Invalid HistogramSet. Got %+v. Expected %+v",
+				pretty.Sprint(histogramSet.ToJSON()),
+				pretty.Sprint(expectedJSON),
+			)
+		}
+	})
+}
+
+func TestAddSharedDiagnostic(t *testing.T) {
+	t.Run("When there are no Histograms in the set", func(t *testing.T) {
+		diagnostic := &catapult.GenericSetDiagnostic{
+			Type:   catapult.DiagnosticTypeGenericSet,
+			GUID:   "test_guid",
+			Values: []string{"test_value"},
+		}
+		histogramSet := catapult.HistogramSet{}
+		histogramSet.AddSharedDiagnostic("test_diagnostic", diagnostic)
+
+		expectedJSON := []interface{}{diagnostic}
+		if !reflect.DeepEqual(histogramSet.ToJSON(), expectedJSON) {
+			t.Errorf("Invalid HistogramSet. Got: %+v\nExpected: %+v",
+				pretty.Sprint(histogramSet.ToJSON()),
+				pretty.Sprint(expectedJSON))
+		}
+
+	})
+
+	t.Run("When there are existing Histograms in the set", func(t *testing.T) {
+		diagnostic := &catapult.GenericSetDiagnostic{
+			Type:   catapult.DiagnosticTypeGenericSet,
+			GUID:   "test_guid",
+			Values: []string{"test_value"},
+		}
+
+		histogramSet := catapult.HistogramSet{}
+		histogramSet.AddHistogram(catapult.Histogram{
+			Name:        "test_histogram_1",
+			Diagnostics: make(map[string]string),
+		})
+		histogramSet.AddHistogram(catapult.Histogram{
+			Name:        "test_histogram_2",
+			Diagnostics: make(map[string]string),
+		})
+		histogramSet.AddSharedDiagnostic("test_diagnostic", diagnostic)
+
+		// Each histogram in the set should have a reference to the diagnostic in its
+		// Diagnostics map.
+		expectedJSON := []interface{}{
+			catapult.Histogram{
+				Name:        "test_histogram_1",
+				Diagnostics: map[string]string{"test_diagnostic": "test_guid"},
+			},
+			catapult.Histogram{
+				Name:        "test_histogram_2",
+				Diagnostics: map[string]string{"test_diagnostic": "test_guid"},
+			},
+			diagnostic,
+		}
+
+		if !reflect.DeepEqual(histogramSet.ToJSON(), expectedJSON) {
+			t.Errorf("Invalid HistogramSet. Got: %+v\nExpected: %+v",
+				pretty.Sprint(histogramSet.ToJSON()),
+				pretty.Sprint(expectedJSON))
+		}
+	})
+}
diff --git a/catapult/histogram_test.go b/catapult/histogram_test.go
index 7beab79..3e27da0 100644
--- a/catapult/histogram_test.go
+++ b/catapult/histogram_test.go
@@ -1,3 +1,6 @@
+// Copyright 2018 The Fuchsia Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file
 package catapult_test
 
 import (
@@ -8,6 +11,7 @@
 	schema "fuchsia.googlesource.com/testing/perf/schema/v1"
 	"github.com/stretchr/testify/assert"
 	"github.com/stretchr/testify/suite"
+	"reflect"
 )
 
 type HistogramTestSuite struct {
@@ -18,6 +22,8 @@
 	suite.Run(t, new(HistogramTestSuite))
 }
 
+// TODO(kjharland): After writing this test the team decided we shouldn't use
+//  assertion libraries. Remove `assert.*` references in a later change.
 func (s *HistogramTestSuite) TestConvertVariantsToHistograms() {
 	var perfTestOutput = []byte(`{
 			"variants": [{
@@ -42,9 +48,19 @@
 
 	json.Unmarshal(perfTestOutput, &variants)
 	histogramSet := ConvertVariantsToHistograms(variants.Variants)
-	histogram := histogramSet[0]
 
-	assert.Equal(s.T(), len(histogramSet), 1)
+	// TODO(kjharland): ConvertVariantsToHistograms should just return a list of
+	// Histograms. Fix and delete this type-check
+	histogramSetJSON := histogramSet.ToJSON()
+	assert.Equal(s.T(), len(histogramSetJSON), 1)
+
+	histogram, ok := histogramSetJSON[0].(Histogram)
+	if !ok {
+		s.T().Errorf(
+			"HistogramSet entry is not a Histogram. Object has type: %T",
+			histogramSetJSON[0])
+	}
+
 	assert.Equal(s.T(), histogram.Name, "example_variant, example_benchmark_data")
 	assert.Len(s.T(), histogram.GUID, 36)
 	assert.Equal(s.T(), histogram.Unit, "ms_smallerIsBetter")
@@ -69,3 +85,55 @@
 		2.5e-10,               // variance
 	})
 }
+
+func (s *HistogramTestSuite) TestHistogram_AddDiagnostic() {
+	var testName, testGUID string
+	var histogram Histogram
+
+	setUp := func() {
+		testName = "test-name"
+		testGUID = "test-guid"
+		histogram = Histogram{}
+		histogram.AddDiagnostic(testName, testGUID)
+	}
+
+	s.T().Run("should do nothing if adding an identical name-guid pair", func(t *testing.T) {
+		setUp()
+		expectedDiagnostics := map[string]string{testName: testGUID}
+
+		histogram.AddDiagnostic(testName, testGUID)
+
+		if !reflect.DeepEqual(expectedDiagnostics, histogram.Diagnostics) {
+			s.T().Errorf("invalid diagnostics map. Expected %v. Got %v",
+				expectedDiagnostics, histogram.Diagnostics)
+		}
+	})
+
+	s.T().Run("should add a new name-guid pair when given a unique name.", func(t *testing.T) {
+		setUp()
+		expectedDiagnostics := map[string]string{
+			testName:         testGUID,
+			"different-name": testGUID,
+		}
+
+		histogram.AddDiagnostic("different-name", testGUID)
+
+		if !reflect.DeepEqual(expectedDiagnostics, histogram.Diagnostics) {
+			s.T().Errorf("invalid diagnostics map. Expected %v. Got %v",
+				expectedDiagnostics, histogram.Diagnostics)
+		}
+	})
+
+	s.T().Run("should overwrite an existing name-guid pair", func(t *testing.T) {
+		setUp()
+		expectedDiagnostics := map[string]string{
+			testName: "different-guid",
+		}
+
+		histogram.AddDiagnostic(testName, "different-guid")
+		if !reflect.DeepEqual(expectedDiagnostics, histogram.Diagnostics) {
+			s.T().Errorf("Diagnostics map was modified. Expected %v. Got %v",
+				expectedDiagnostics, histogram.Diagnostics)
+		}
+	})
+}