diff --git a/harness/determined/common/api/bindings.py b/harness/determined/common/api/bindings.py index 13b716b509b..44ed7c85b38 100644 --- a/harness/determined/common/api/bindings.py +++ b/harness/determined/common/api/bindings.py @@ -14020,8 +14020,10 @@ class v1RunGroup(Printable): duration: "typing.Optional[int]" = None endTime: "typing.Optional[str]" = None groupName: "typing.Optional[str]" = None + hyperparameters: "typing.Optional[typing.Dict[str, typing.Any]]" = None resourcePools: "typing.Optional[typing.Sequence[str]]" = None runCount: "typing.Optional[int]" = None + runIds: "typing.Optional[typing.Sequence[int]]" = None searcherMetricValue: "typing.Optional[float]" = None searcherMetrics: "typing.Optional[typing.Sequence[str]]" = None searcherTypes: "typing.Optional[typing.Sequence[str]]" = None @@ -14036,8 +14038,10 @@ def __init__( duration: "typing.Union[int, None, Unset]" = _unset, endTime: "typing.Union[str, None, Unset]" = _unset, groupName: "typing.Union[str, None, Unset]" = _unset, + hyperparameters: "typing.Union[typing.Dict[str, typing.Any], None, Unset]" = _unset, resourcePools: "typing.Union[typing.Sequence[str], None, Unset]" = _unset, runCount: "typing.Union[int, None, Unset]" = _unset, + runIds: "typing.Union[typing.Sequence[int], None, Unset]" = _unset, searcherMetricValue: "typing.Union[float, None, Unset]" = _unset, searcherMetrics: "typing.Union[typing.Sequence[str], None, Unset]" = _unset, searcherTypes: "typing.Union[typing.Sequence[str], None, Unset]" = _unset, @@ -14052,10 +14056,14 @@ def __init__( self.endTime = endTime if not isinstance(groupName, Unset): self.groupName = groupName + if not isinstance(hyperparameters, Unset): + self.hyperparameters = hyperparameters if not isinstance(resourcePools, Unset): self.resourcePools = resourcePools if not isinstance(runCount, Unset): self.runCount = runCount + if not isinstance(runIds, Unset): + self.runIds = runIds if not isinstance(searcherMetricValue, Unset): self.searcherMetricValue = searcherMetricValue if not isinstance(searcherMetrics, Unset): @@ -14078,10 +14086,14 @@ def from_json(cls, obj: Json) -> "v1RunGroup": kwargs["endTime"] = obj["endTime"] if "groupName" in obj: kwargs["groupName"] = obj["groupName"] + if "hyperparameters" in obj: + kwargs["hyperparameters"] = obj["hyperparameters"] if "resourcePools" in obj: kwargs["resourcePools"] = obj["resourcePools"] if "runCount" in obj: kwargs["runCount"] = obj["runCount"] + if "runIds" in obj: + kwargs["runIds"] = obj["runIds"] if "searcherMetricValue" in obj: kwargs["searcherMetricValue"] = float(obj["searcherMetricValue"]) if obj["searcherMetricValue"] is not None else None if "searcherMetrics" in obj: @@ -14104,10 +14116,14 @@ def to_json(self, omit_unset: bool = False) -> typing.Dict[str, typing.Any]: out["endTime"] = self.endTime if not omit_unset or "groupName" in vars(self): out["groupName"] = self.groupName + if not omit_unset or "hyperparameters" in vars(self): + out["hyperparameters"] = self.hyperparameters if not omit_unset or "resourcePools" in vars(self): out["resourcePools"] = self.resourcePools if not omit_unset or "runCount" in vars(self): out["runCount"] = self.runCount + if not omit_unset or "runIds" in vars(self): + out["runIds"] = self.runIds if not omit_unset or "searcherMetricValue" in vars(self): out["searcherMetricValue"] = None if self.searcherMetricValue is None else dump_float(self.searcherMetricValue) if not omit_unset or "searcherMetrics" in vars(self): diff --git a/master/internal/api_runs.go b/master/internal/api_runs.go index e32b4d94ff9..98a024c349a 100644 --- a/master/internal/api_runs.go +++ b/master/internal/api_runs.go @@ -1091,9 +1091,8 @@ func (a *apiServer) GetRunGroups(ctx context.Context, req *apiv1.GetRunGroupsReq resp := &apiv1.GetRunGroupsResponse{} var groups []*runv1.RunGroup - query := db.Bun().NewSelect(). - Model(&groups). - ModelTableExpr("runs AS r"). + searchQuery := db.Bun().NewSelect(). + TableExpr("runs AS r"). Apply(getRunsGroupsColumns) var proj *projectv1.Project @@ -1103,36 +1102,64 @@ func (a *apiServer) GetRunGroups(ctx context.Context, req *apiv1.GetRunGroupsReq return nil, err } - query = query.Where("r.project_id = ?", req.ProjectId) + searchQuery = searchQuery.Where("r.project_id = ?", req.ProjectId) } - if query, err = experiment.AuthZProvider.Get(). - FilterExperimentsQuery(ctx, *curUser, proj, query, + if searchQuery, err = experiment.AuthZProvider.Get(). + FilterExperimentsQuery(ctx, *curUser, proj, searchQuery, []rbacv1.PermissionType{rbacv1.PermissionType_PERMISSION_TYPE_VIEW_EXPERIMENT_METADATA}, ); err != nil { return nil, err } if req.Filter != nil { - query, err = filterRunQuery(query, req.Filter) + searchQuery, err = filterRunQuery(searchQuery, req.Filter) if err != nil { return nil, err } } if req.Sort != nil { - err = sortRuns(req.Sort, query, false) + err = sortRuns(req.Sort, searchQuery, false) if err != nil { return nil, err } } else { - query.OrderExpr("group_name ASC") + searchQuery.OrderExpr("group_name ASC") } groupName, err := runColumnNameToSQL(req.Group) if err != nil { return nil, err } - query.Group(groupName) - query.ColumnExpr(fmt.Sprintf("%s AS group_name", groupName)) + searchQuery.Group(groupName) + searchQuery.ColumnExpr(fmt.Sprintf("%s AS group_name", groupName)) + + query := db.Bun().NewSelect(). + ColumnExpr("run_groups.group_name"). + ColumnExpr("run_groups.start_time"). + ColumnExpr("run_groups.end_time"). + ColumnExpr("run_groups.checkpoint_size"). + ColumnExpr("run_groups.checkpoint_count"). + ColumnExpr("run_groups.searcher_metric_value"). + ColumnExpr("run_groups.duration"). + ColumnExpr("run_groups.user_ids"). + ColumnExpr("run_groups.resource_pools"). + ColumnExpr("run_groups.searcher_types"). + ColumnExpr("run_groups.searcher_metrics"). + ColumnExpr("run_groups.run_count"). + ColumnExpr("array_to_json(run_groups.run_ids) as run_ids"). + ColumnExpr(`(SELECT jsonb_concat_agg(jsonb_build_object(s.hparam, + jsonb_build_object( + 'number_val', s.number_val, + 'text_val', s.text_val, + 'bool_val', s.bool_val) + )) FROM ( + SELECT hparam, avg(number_val) as number_val, + json_agg(distinct coalesce(text_val, '')) as text_val, + bool_or(bool_val) as bool_val + FROM run_hparams rh WHERE rh.run_id = ANY (run_groups.run_ids) + GROUP BY rh.hparam) as s) as hyperparameters`). + Model(&groups). + ModelTableExpr("(?) as run_groups", searchQuery) pagination, err := runPagedBunExperimentsQuery(ctx, query, int(req.Offset), int(req.Limit)) if err != nil { return nil, err @@ -1155,6 +1182,7 @@ func getRunsGroupsColumns(q *bun.SelectQuery) *bun.SelectQuery { ColumnExpr("json_agg(distinct e.config->'searcher'->>'name') as searcher_types"). ColumnExpr("json_agg(distinct e.config->'searcher'->>'metric') as searcher_metrics"). ColumnExpr("COUNT(*) AS run_count"). + ColumnExpr("array_agg(r.id) as run_ids"). Join("LEFT JOIN experiments AS e ON r.experiment_id=e.id"). Join("LEFT JOIN runs_metadata AS rm ON r.id=rm.run_id"). Join("LEFT JOIN users u ON e.owner_id = u.id"). diff --git a/master/internal/api_runs_intg_test.go b/master/internal/api_runs_intg_test.go index 5e680cb8bdc..35b7f82f013 100644 --- a/master/internal/api_runs_intg_test.go +++ b/master/internal/api_runs_intg_test.go @@ -1790,7 +1790,12 @@ func TestGetRunGroups(t *testing.T) { Sort: ptrs.Ptr("state=asc"), } - hyperparameters := map[string]any{"global_batch_size": 1, "test1": map[string]any{"test2": 1}} + hyperparameters := map[string]any{ + "global_batch_size": 1, + "test1": map[string]any{"test2": 1}, + "stringH": "abc", + "boolH": false, + } exp := createTestExpWithProjectID(t, api, curUser, projectIDInt) @@ -1839,4 +1844,45 @@ func TestGetRunGroups(t *testing.T) { resp, err = api.GetRunGroups(ctx, req) require.NoError(t, err) require.Len(t, resp.Groups, 1) + + // Add new task with different hyperperameter values + newHparams := map[string]any{ + "global_batch_size": 9, + "test1": map[string]any{"test2": 8}, + "stringH": "def", + "boolH": true, + } + + exp3 := createTestExpWithProjectID(t, api, curUser, projectIDInt) + + task = &model.Task{TaskType: model.TaskTypeTrial, TaskID: model.NewTaskID()} + require.NoError(t, db.AddTask(ctx, task)) + require.NoError(t, db.AddTrial(ctx, &model.Trial{ + State: model.PausedState, + ExperimentID: exp3.ID, + StartTime: time.Now(), + HParams: newHparams, + }, task.TaskID)) + + req = &apiv1.GetRunGroupsRequest{ + ProjectId: &projectID, + Group: "state", + Sort: ptrs.Ptr("state=asc"), + } + + resp, err = api.GetRunGroups(ctx, req) + require.NoError(t, err) + require.Len(t, resp.Groups, 2) + require.Equal(t, string(model.PausedState), resp.Groups[0].GroupName) + require.Equal(t, string(model.CanceledState), resp.Groups[1].GroupName) + require.InEpsilon(t, float64(5), resp.Groups[0].Hyperparameters.Fields["global_batch_size"]. + GetStructValue().Fields["number_val"].GetNumberValue(), 0.00001) + require.InEpsilon(t, 4.5, resp.Groups[0].Hyperparameters.Fields["test1.test2"]. + GetStructValue().Fields["number_val"].GetNumberValue(), 0.00001) + require.Equal(t, "abc", resp.Groups[0].Hyperparameters.Fields["stringH"]. + GetStructValue().Fields["text_val"].GetListValue().Values[0].GetStringValue()) + require.Equal(t, "def", resp.Groups[0].Hyperparameters.Fields["stringH"]. + GetStructValue().Fields["text_val"].GetListValue().Values[1].GetStringValue()) + require.True(t, resp.Groups[0].Hyperparameters.Fields["boolH"]. + GetStructValue().Fields["bool_val"].GetBoolValue()) } diff --git a/master/static/migrations/20241031154311_add-json-concat-aggregate.tx.up.sql b/master/static/migrations/20241031154311_add-json-concat-aggregate.tx.up.sql new file mode 100644 index 00000000000..fa2049faf03 --- /dev/null +++ b/master/static/migrations/20241031154311_add-json-concat-aggregate.tx.up.sql @@ -0,0 +1,4 @@ +create aggregate jsonb_concat_agg(jsonb)( + sfunc = jsonb_concat(jsonb, jsonb), + stype = jsonb +); diff --git a/performance/daist/daist/rest_api/tasks.py b/performance/daist/daist/rest_api/tasks.py index ac682b8be0a..7fbbe553373 100644 --- a/performance/daist/daist/rest_api/tasks.py +++ b/performance/daist/daist/rest_api/tasks.py @@ -92,6 +92,10 @@ def read_only_tasks(resources: Resources) -> LocustTasksWithMeta: tasks.append(LocustGetTaskWithMeta( f"/api/v1/projects/{resources.project_id}/columns", test_name="get project columns")) + + tasks.append(LocustGetTaskWithMeta( + f"/api/v1/runs/groups?projectId={resources.project_id}&group=state", + test_name="get groups")) if resources.workspace_id is not None: tasks.append(LocustGetTaskWithMeta( diff --git a/proto/pkg/runv1/run.pb.go b/proto/pkg/runv1/run.pb.go index 66668de8587..26cda4d164e 100644 --- a/proto/pkg/runv1/run.pb.go +++ b/proto/pkg/runv1/run.pb.go @@ -877,6 +877,10 @@ type RunGroup struct { ResourcePools []string `protobuf:"bytes,11,rep,name=resource_pools,json=resourcePools,proto3" json:"resource_pools,omitempty"` // The number of runs in the group. RunCount int32 `protobuf:"varint,12,opt,name=run_count,json=runCount,proto3" json:"run_count,omitempty"` + // The run ids of the runs that belong to the group + RunIds []int32 `protobuf:"varint,13,rep,packed,name=run_ids,json=runIds,proto3" json:"run_ids,omitempty"` + // hyperparameters. + Hyperparameters *_struct.Struct `protobuf:"bytes,14,opt,name=hyperparameters,proto3,oneof" json:"hyperparameters,omitempty"` } func (x *RunGroup) Reset() { @@ -995,6 +999,20 @@ func (x *RunGroup) GetRunCount() int32 { return 0 } +func (x *RunGroup) GetRunIds() []int32 { + if x != nil { + return x.RunIds + } + return nil +} + +func (x *RunGroup) GetHyperparameters() *_struct.Struct { + if x != nil { + return x.Hyperparameters + } + return nil +} + var File_determined_run_v1_run_proto protoreflect.FileDescriptor var file_determined_run_v1_run_proto_rawDesc = []byte{ @@ -1276,7 +1294,7 @@ var file_determined_run_v1_run_proto_rawDesc = []byte{ 0x0a, 0x16, 0x5f, 0x70, 0x61, 0x63, 0x68, 0x79, 0x64, 0x65, 0x72, 0x6d, 0x5f, 0x69, 0x6e, 0x74, 0x65, 0x67, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x42, 0x15, 0x0a, 0x13, 0x5f, 0x6c, 0x6f, 0x67, 0x5f, 0x72, 0x65, 0x74, 0x65, 0x6e, 0x74, 0x69, 0x6f, 0x6e, 0x5f, 0x64, 0x61, 0x79, 0x73, 0x22, - 0xe2, 0x04, 0x0a, 0x08, 0x52, 0x75, 0x6e, 0x47, 0x72, 0x6f, 0x75, 0x70, 0x12, 0x1d, 0x0a, 0x0a, + 0xd7, 0x05, 0x0a, 0x08, 0x52, 0x75, 0x6e, 0x47, 0x72, 0x6f, 0x75, 0x70, 0x12, 0x1d, 0x0a, 0x0a, 0x67, 0x72, 0x6f, 0x75, 0x70, 0x5f, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x67, 0x72, 0x6f, 0x75, 0x70, 0x4e, 0x61, 0x6d, 0x65, 0x12, 0x39, 0x0a, 0x0a, 0x73, 0x74, 0x61, 0x72, 0x74, 0x5f, 0x74, 0x69, 0x6d, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, @@ -1307,18 +1325,25 @@ var file_determined_run_v1_run_proto_rawDesc = []byte{ 0x75, 0x72, 0x63, 0x65, 0x5f, 0x70, 0x6f, 0x6f, 0x6c, 0x73, 0x18, 0x0b, 0x20, 0x03, 0x28, 0x09, 0x52, 0x0d, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x50, 0x6f, 0x6f, 0x6c, 0x73, 0x12, 0x1b, 0x0a, 0x09, 0x72, 0x75, 0x6e, 0x5f, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x18, 0x0c, 0x20, 0x01, - 0x28, 0x05, 0x52, 0x08, 0x72, 0x75, 0x6e, 0x43, 0x6f, 0x75, 0x6e, 0x74, 0x3a, 0x3f, 0x92, 0x41, - 0x3c, 0x0a, 0x3a, 0xd2, 0x01, 0x0a, 0x73, 0x74, 0x61, 0x72, 0x74, 0x5f, 0x74, 0x69, 0x6d, 0x65, - 0xd2, 0x01, 0x05, 0x73, 0x74, 0x61, 0x74, 0x65, 0xd2, 0x01, 0x0f, 0x63, 0x68, 0x65, 0x63, 0x6b, - 0x70, 0x6f, 0x69, 0x6e, 0x74, 0x5f, 0x73, 0x69, 0x7a, 0x65, 0xd2, 0x01, 0x10, 0x63, 0x68, 0x65, - 0x63, 0x6b, 0x70, 0x6f, 0x69, 0x6e, 0x74, 0x5f, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x42, 0x18, 0x0a, - 0x16, 0x5f, 0x73, 0x65, 0x61, 0x72, 0x63, 0x68, 0x65, 0x72, 0x5f, 0x6d, 0x65, 0x74, 0x72, 0x69, - 0x63, 0x5f, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x42, 0x0b, 0x0a, 0x09, 0x5f, 0x64, 0x75, 0x72, 0x61, - 0x74, 0x69, 0x6f, 0x6e, 0x42, 0x35, 0x5a, 0x33, 0x67, 0x69, 0x74, 0x68, 0x75, 0x62, 0x2e, 0x63, - 0x6f, 0x6d, 0x2f, 0x64, 0x65, 0x74, 0x65, 0x72, 0x6d, 0x69, 0x6e, 0x65, 0x64, 0x2d, 0x61, 0x69, - 0x2f, 0x64, 0x65, 0x74, 0x65, 0x72, 0x6d, 0x69, 0x6e, 0x65, 0x64, 0x2f, 0x70, 0x72, 0x6f, 0x74, - 0x6f, 0x2f, 0x70, 0x6b, 0x67, 0x2f, 0x72, 0x75, 0x6e, 0x76, 0x31, 0x62, 0x06, 0x70, 0x72, 0x6f, - 0x74, 0x6f, 0x33, + 0x28, 0x05, 0x52, 0x08, 0x72, 0x75, 0x6e, 0x43, 0x6f, 0x75, 0x6e, 0x74, 0x12, 0x17, 0x0a, 0x07, + 0x72, 0x75, 0x6e, 0x5f, 0x69, 0x64, 0x73, 0x18, 0x0d, 0x20, 0x03, 0x28, 0x05, 0x52, 0x06, 0x72, + 0x75, 0x6e, 0x49, 0x64, 0x73, 0x12, 0x46, 0x0a, 0x0f, 0x68, 0x79, 0x70, 0x65, 0x72, 0x70, 0x61, + 0x72, 0x61, 0x6d, 0x65, 0x74, 0x65, 0x72, 0x73, 0x18, 0x0e, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x17, + 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, + 0x2e, 0x53, 0x74, 0x72, 0x75, 0x63, 0x74, 0x48, 0x02, 0x52, 0x0f, 0x68, 0x79, 0x70, 0x65, 0x72, + 0x70, 0x61, 0x72, 0x61, 0x6d, 0x65, 0x74, 0x65, 0x72, 0x73, 0x88, 0x01, 0x01, 0x3a, 0x3f, 0x92, + 0x41, 0x3c, 0x0a, 0x3a, 0xd2, 0x01, 0x0a, 0x73, 0x74, 0x61, 0x72, 0x74, 0x5f, 0x74, 0x69, 0x6d, + 0x65, 0xd2, 0x01, 0x05, 0x73, 0x74, 0x61, 0x74, 0x65, 0xd2, 0x01, 0x0f, 0x63, 0x68, 0x65, 0x63, + 0x6b, 0x70, 0x6f, 0x69, 0x6e, 0x74, 0x5f, 0x73, 0x69, 0x7a, 0x65, 0xd2, 0x01, 0x10, 0x63, 0x68, + 0x65, 0x63, 0x6b, 0x70, 0x6f, 0x69, 0x6e, 0x74, 0x5f, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x42, 0x18, + 0x0a, 0x16, 0x5f, 0x73, 0x65, 0x61, 0x72, 0x63, 0x68, 0x65, 0x72, 0x5f, 0x6d, 0x65, 0x74, 0x72, + 0x69, 0x63, 0x5f, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x42, 0x0b, 0x0a, 0x09, 0x5f, 0x64, 0x75, 0x72, + 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x42, 0x12, 0x0a, 0x10, 0x5f, 0x68, 0x79, 0x70, 0x65, 0x72, 0x70, + 0x61, 0x72, 0x61, 0x6d, 0x65, 0x74, 0x65, 0x72, 0x73, 0x42, 0x35, 0x5a, 0x33, 0x67, 0x69, 0x74, + 0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x64, 0x65, 0x74, 0x65, 0x72, 0x6d, 0x69, 0x6e, + 0x65, 0x64, 0x2d, 0x61, 0x69, 0x2f, 0x64, 0x65, 0x74, 0x65, 0x72, 0x6d, 0x69, 0x6e, 0x65, 0x64, + 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2f, 0x70, 0x6b, 0x67, 0x2f, 0x72, 0x75, 0x6e, 0x76, 0x31, + 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, } var ( @@ -1366,11 +1391,12 @@ var file_determined_run_v1_run_proto_depIdxs = []int32{ 8, // 17: determined.run.v1.Run.best_checkpoint:type_name -> determined.trial.v1.CheckpointWorkload 5, // 18: determined.run.v1.RunGroup.start_time:type_name -> google.protobuf.Timestamp 5, // 19: determined.run.v1.RunGroup.end_time:type_name -> google.protobuf.Timestamp - 20, // [20:20] is the sub-list for method output_type - 20, // [20:20] is the sub-list for method input_type - 20, // [20:20] is the sub-list for extension type_name - 20, // [20:20] is the sub-list for extension extendee - 0, // [0:20] is the sub-list for field type_name + 4, // 20: determined.run.v1.RunGroup.hyperparameters:type_name -> google.protobuf.Struct + 21, // [21:21] is the sub-list for method output_type + 21, // [21:21] is the sub-list for method input_type + 21, // [21:21] is the sub-list for extension type_name + 21, // [21:21] is the sub-list for extension extendee + 0, // [0:21] is the sub-list for field type_name } func init() { file_determined_run_v1_run_proto_init() } diff --git a/proto/src/determined/run/v1/run.proto b/proto/src/determined/run/v1/run.proto index 37ec8aefacd..3511d14d16d 100644 --- a/proto/src/determined/run/v1/run.proto +++ b/proto/src/determined/run/v1/run.proto @@ -268,4 +268,8 @@ message RunGroup { repeated string resource_pools = 11; // The number of runs in the group. int32 run_count = 12; + // The run ids of the runs that belong to the group + repeated int32 run_ids = 13; + // hyperparameters. + optional google.protobuf.Struct hyperparameters = 14; } diff --git a/webui/react/src/services/api-ts-sdk/api.ts b/webui/react/src/services/api-ts-sdk/api.ts index 1097fef19d8..2e82dae49f7 100644 --- a/webui/react/src/services/api-ts-sdk/api.ts +++ b/webui/react/src/services/api-ts-sdk/api.ts @@ -10383,6 +10383,18 @@ export interface V1RunGroup { * @memberof V1RunGroup */ runCount?: number; + /** + * The run ids of the runs that belong to the group + * @type {Array} + * @memberof V1RunGroup + */ + runIds?: Array; + /** + * hyperparameters. + * @type {any} + * @memberof V1RunGroup + */ + hyperparameters?: any; } /** * RunnableOperation represents a single runnable operation emitted by a searcher.