diff --git a/README.md b/README.md
index b51582f..947e27e 100644
--- a/README.md
+++ b/README.md
@@ -3,10 +3,10 @@
SciMesh is a scientific-workload framework for molecular datasets. Its public CLI
runs exact similarity search and sparse similarity-graph construction locally in
one Python process; it creates no dense similarity matrix. The Go/PostgreSQL
-coordinator and Python worker can run a diagnostic, shard-based
-`similarity-search` pipeline locally. Its CSV artifacts are not a global result
-until CTX-07--09 add planning and reduction; use the local CLI for scientific
-results today. See [`STATUS.md`](STATUS.md).
+coordinator and Python worker can run a shard-based `similarity-search`
+pipeline locally. After every shard succeeds, the coordinator deterministically
+merges its candidates into one final global top-k CSV. See
+[`STATUS.md`](STATUS.md).
The ChEMBL TSV database is intentionally not included in this repository. Download it separately and pass its path to the commands below. The expected columns are `chembl_id` and `canonical_smiles`.
diff --git a/STATUS.md b/STATUS.md
index cebfd27..1555d37 100644
--- a/STATUS.md
+++ b/STATUS.md
@@ -1,7 +1,7 @@
# SciMesh Status
**Updated:** 2026-07-24
-**Branch baseline:** `main` at `f953112` (distributed pipeline hardening)
+**Branch baseline:** `main` at `6e67daa` (distributed similarity-search)
## Current state
@@ -18,8 +18,10 @@ the reference behaviour for future distributed execution:
The Go coordinator and its PostgreSQL-backed task lifecycle are implemented:
registration, atomic claiming, lease renewal, artifact storage, dataset
chunking, result/failure reporting, and job progress. The Python worker now
-uses the live coordinator contract; its HTTP path was exercised against a real
-Docker PostgreSQL stack on 2026-07-23.
+uses the live coordinator contract. Completed similarity-search shard results
+are reduced once into a checksum-protected final CSV, which is downloadable
+through the coordinator. The full Go checks (including a fresh migration and
+real PostgreSQL smoke test) passed on 2026-07-24.
## Milestone tracker
@@ -33,30 +35,24 @@ Docker PostgreSQL stack on 2026-07-23.
| CTX-05 Artifact storage | Implemented | Coordinator-owned inputs/results, checksum verification, and upload flow. |
| CTX-06 Python Worker live-contract alignment | Implemented | Worker completed a real uploaded shard via HTTP on 2026-07-23. |
| CTX-07 Distributed workload protocol | Implemented | Versioned Python contract models, registry, strict plan validation, and deterministic reduction ordering are in `scimesh/distributed/`. |
-| CTX-08 Distributed similarity-search | Implemented (scientific layer) | Python planner resolves `query_id` once, creates deterministic shard plans, worker adapter emits exact partial top-k CSVs/metrics, and reducer matches the local reference. Coordinator persistence/orchestration remains CTX-09. |
-| CTX-09 Reducer and final-result API | Not started | Depends on CTX-07 and CTX-08. |
+| CTX-08 Distributed similarity-search | Implemented | Python planner resolves `query_id` once, creates deterministic shard plans, worker adapter emits exact partial top-k CSVs/metrics, and reducer matches the local reference. |
+| CTX-09 Reducer and final-result API | Implemented | Atomic `reducing` claim, deterministic coordinator-side top-k reducer, sanitized reducer failure, final artifact persistence, `result_uri`, and final CSV download. |
| CTX-10 Distributed similarity-graph | Not started | Local reference exists. |
-| CTX-11 Dashboard/operator view | Implemented (diagnostic scope) | Protected local view: job/task/worker status, validated similarity-search upload, diagnostic partial-artifact download, and bounded polling. Final-result reduction remains CTX-09. |
+| CTX-11 Dashboard/operator view | Implemented | Protected local view: job/task/worker status, validated similarity-search upload, partial-artifact diagnostics, final-result download, and bounded polling. |
| CTX-12 Reliability, security, CI | In progress | Unit, race, PostgreSQL integration, and smoke checks exist; CI hardening remains. |
## Next recommended assignment
-Assign **CTX-09** to the coordinator role: materialize planned shards,
-persist them transactionally, invoke the registered reducer once, and expose a
-durable final artifact.
+Assign **CTX-10** to the distributed-science role: implement deterministic
+block-pair planning and reduction for `similarity-graph`.
## Known constraints
-- The Python `similarity-search` planner/reducer is implemented, but the Go
- coordinator does not yet invoke it or persist its final artifact. The
- operator UI labels `partial_result` files as diagnostic and cannot present
- them as final output.
- Use the local `scimesh` CLI for complete workload results.
- The worker/coordinator flow currently accepts both underscore API workload
names and hyphenated CLI names while the contract is consolidated.
- A real-stack worker test uses a small `query_smiles` shard. The Python
- planner resolves `query_id` once and shares `query_smiles`; connecting that
- planner to uploaded coordinator jobs belongs to CTX-09.
+ planner resolves `query_id` once and shares `query_smiles`; the upload UI
+ currently accepts `query_smiles` only.
- The coordinator accepts uploaded distributed jobs only for
`similarity-search` with `query_smiles`. It rejects `similarity-graph` until
CTX-10 supplies cross-shard pair planning.
diff --git a/coordinator/README.md b/coordinator/README.md
index 43239cc..eeebca2 100644
--- a/coordinator/README.md
+++ b/coordinator/README.md
@@ -76,9 +76,10 @@ UI_AUTH_TOKEN='local-ui-secret' make up
```
The UI is disabled by default and never accepts the worker bearer token.
-It shows recent jobs, task/worker state, and the per-job partial artifacts.
-Those files are explicitly diagnostic until the CTX-09 reducer creates a final
-result; the UI does not present them as final scientific output.
+It shows recent jobs, task/worker state, diagnostic shard artifacts, and the
+final CSV for completed similarity-search jobs. The coordinator enters
+`reducing` after the last shard completes, then exposes the final deterministic
+global top-k result when merging succeeds.
`up` starts three services in order: Postgres waits until `pg_isready` passes, a
one-shot `migrate` container applies the schema and exits, and only then does the
diff --git a/coordinator/cmd/coordinator/main.go b/coordinator/cmd/coordinator/main.go
index 8447414..a200584 100644
--- a/coordinator/cmd/coordinator/main.go
+++ b/coordinator/cmd/coordinator/main.go
@@ -75,11 +75,13 @@ func run() error {
ClaimTask: usecase.NewClaimTask(taskRepo, jobRepo, workerRepo, tx, clk, cfg.LeaseDuration),
RenewLease: usecase.NewRenewLease(taskRepo, workerRepo, tx, clk, cfg.LeaseDuration),
CompleteTask: usecase.NewCompleteTask(taskRepo, jobRepo, artifactRepo, tx, clk),
+ ReduceJob: usecase.NewReduceJob(jobRepo, taskRepo, artifactRepo, blobStore, tx, clk),
FailTask: usecase.NewFailTask(taskRepo, jobRepo, tx, clk),
GetJobStatus: usecase.NewGetJobStatus(jobRepo, taskRepo),
CancelJob: usecase.NewCancelJob(jobRepo, taskRepo, tx, clk),
UploadArtifact: usecase.NewUploadArtifact(taskRepo, artifactRepo, blobStore, tx, clk),
DownloadArtifact: usecase.NewDownloadArtifact(artifactRepo, blobStore),
+ GetJobResult: usecase.NewGetJobResult(jobRepo, usecase.NewDownloadArtifact(artifactRepo, blobStore)),
GetTaskInput: usecase.NewGetTaskInput(taskRepo, artifactRepo, blobStore),
Dashboard: usecase.NewDashboard(uiReadRepo),
}
diff --git a/coordinator/internal/domain/job.go b/coordinator/internal/domain/job.go
index 7d6be3d..94ff189 100644
--- a/coordinator/internal/domain/job.go
+++ b/coordinator/internal/domain/job.go
@@ -11,6 +11,7 @@ type JobStatus string
const (
JobPending JobStatus = "pending"
JobRunning JobStatus = "running"
+ JobReducing JobStatus = "reducing"
JobCompleted JobStatus = "completed"
JobFailed JobStatus = "failed"
JobCancelled JobStatus = "cancelled"
@@ -18,14 +19,18 @@ const (
// Job is one user submission that fans out into one or more tasks.
type Job struct {
- ID uuid.UUID
- Workload string
- InputURI string // external input URI; empty for uploaded datasets
- InputArtifactID *uuid.UUID // uploaded input artifact; nil for URI submissions
- Parameters map[string]any
- Status JobStatus
- CreatedAt time.Time
- CompletedAt *time.Time
+ ID uuid.UUID
+ Workload string
+ InputURI string // external input URI; empty for uploaded datasets
+ InputArtifactID *uuid.UUID // uploaded input artifact; nil for URI submissions
+ ResultArtifactID *uuid.UUID
+ Parameters map[string]any
+ Status JobStatus
+ CreatedAt time.Time
+ CompletedAt *time.Time
+ ReducerStartedAt *time.Time
+ ErrorCode *string
+ ErrorMessage *string
}
// NewUploadedJob builds a job whose input was uploaded to the coordinator. The
@@ -114,6 +119,8 @@ func (p JobProgress) DeriveStatus() JobStatus {
switch {
case p.Job.Status == JobCancelled:
return JobCancelled
+ case p.Job.Status == JobReducing:
+ return JobReducing
case p.Total == 0:
return JobPending
case p.Done == p.Total:
diff --git a/coordinator/internal/memstore/memstore.go b/coordinator/internal/memstore/memstore.go
index c7f1076..28f4318 100644
--- a/coordinator/internal/memstore/memstore.go
+++ b/coordinator/internal/memstore/memstore.go
@@ -216,6 +216,51 @@ func (r *JobRepo) UpdateStatus(ctx context.Context, id uuid.UUID, status domain.
return nil
}
+func (r *JobRepo) ClaimReduction(_ context.Context, id uuid.UUID, startedAt time.Time) (bool, error) {
+ r.mu.Lock()
+ defer r.mu.Unlock()
+ j, ok := r.jobs[id]
+ if !ok {
+ return false, domain.ErrJobNotFound
+ }
+ if j.Status != domain.JobReducing || j.ReducerStartedAt != nil {
+ return false, nil
+ }
+ j.ReducerStartedAt = &startedAt
+ return true, nil
+}
+
+func (r *JobRepo) CompleteWithResult(_ context.Context, id, resultArtifactID uuid.UUID, completedAt time.Time) error {
+ r.mu.Lock()
+ defer r.mu.Unlock()
+ j, ok := r.jobs[id]
+ if !ok {
+ return domain.ErrJobNotFound
+ }
+ j.ResultArtifactID = &resultArtifactID
+ j.Status = domain.JobCompleted
+ j.CompletedAt = &completedAt
+ j.ReducerStartedAt = nil
+ j.ErrorCode = nil
+ j.ErrorMessage = nil
+ return nil
+}
+
+func (r *JobRepo) FailReduction(_ context.Context, id uuid.UUID, code, message string, completedAt time.Time) error {
+ r.mu.Lock()
+ defer r.mu.Unlock()
+ j, ok := r.jobs[id]
+ if !ok {
+ return domain.ErrJobNotFound
+ }
+ j.Status = domain.JobFailed
+ j.CompletedAt = &completedAt
+ j.ReducerStartedAt = nil
+ j.ErrorCode = &code
+ j.ErrorMessage = &message
+ return nil
+}
+
// --- WorkerRepo ----------------------------------------------------------
type WorkerRepo struct {
diff --git a/coordinator/internal/reducer/similarity_search.go b/coordinator/internal/reducer/similarity_search.go
new file mode 100644
index 0000000..0d43a53
--- /dev/null
+++ b/coordinator/internal/reducer/similarity_search.go
@@ -0,0 +1,195 @@
+// Package reducer contains deterministic, coordinator-side result reductions.
+package reducer
+
+import (
+ "bytes"
+ "encoding/csv"
+ "errors"
+ "fmt"
+ "io"
+ "math"
+ "sort"
+ "strconv"
+)
+
+var searchHeader = []string{"rank", "chembl_id", "canonical_smiles", "similarity"}
+
+type similarityMatch struct {
+ similarity float64
+ id string
+ smiles string
+}
+
+// ReduceSimilaritySearch streams worker-local top-k CSVs into the exact global
+// top-k. Each partial is validated before it can affect the final artifact.
+func ReduceSimilaritySearch(partials []io.Reader, parameters map[string]any) ([]byte, error) {
+ topK, err := positiveInt(parameters["top_k"], 20)
+ if err != nil {
+ return nil, err
+ }
+ direction, err := thresholdDirection(parameters["threshold_direction"])
+ if err != nil {
+ return nil, err
+ }
+ h := &matchHeap{direction: direction}
+ for _, partial := range partials {
+ if err := readPartial(partial, direction, func(match similarityMatch) {
+ if len(h.items) < topK {
+ heapPush(h, match)
+ return
+ }
+ if better(match, h.items[0], direction) {
+ h.items[0] = match
+ heapDown(h, 0)
+ }
+ }); err != nil {
+ return nil, err
+ }
+ }
+
+ matches := append([]similarityMatch(nil), h.items...)
+ sort.Slice(matches, func(i, j int) bool { return better(matches[i], matches[j], direction) })
+ var out bytes.Buffer
+ writer := csv.NewWriter(&out)
+ if err := writer.Write(searchHeader); err != nil {
+ return nil, err
+ }
+ for index, match := range matches {
+ if err := writer.Write([]string{
+ strconv.Itoa(index + 1), match.id, match.smiles, fmt.Sprintf("%.6f", match.similarity),
+ }); err != nil {
+ return nil, err
+ }
+ }
+ writer.Flush()
+ if err := writer.Error(); err != nil {
+ return nil, err
+ }
+ return out.Bytes(), nil
+}
+
+func readPartial(input io.Reader, direction string, consume func(similarityMatch)) error {
+ reader := csv.NewReader(input)
+ header, err := reader.Read()
+ if err != nil {
+ return fmt.Errorf("read partial header: %w", err)
+ }
+ if !equalStrings(header, searchHeader) {
+ return fmt.Errorf("partial result has an invalid CSV header")
+ }
+ var previous *similarityMatch
+ for rank := 1; ; rank++ {
+ row, err := reader.Read()
+ if errors.Is(err, io.EOF) {
+ return nil
+ }
+ if err != nil {
+ return fmt.Errorf("read partial row: %w", err)
+ }
+ if len(row) != len(searchHeader) || row[0] != strconv.Itoa(rank) {
+ return fmt.Errorf("partial result has an invalid rank")
+ }
+ score, err := strconv.ParseFloat(row[3], 64)
+ if err != nil || math.IsNaN(score) || math.IsInf(score, 0) || score < 0 || score > 1 {
+ return fmt.Errorf("partial result has an invalid similarity")
+ }
+ match := similarityMatch{similarity: score, id: row[1], smiles: row[2]}
+ if previous != nil && better(match, *previous, direction) {
+ return fmt.Errorf("partial result is not sorted deterministically")
+ }
+ previous = &match
+ consume(match)
+ }
+}
+
+func positiveInt(value any, fallback int) (int, error) {
+ if value == nil {
+ return fallback, nil
+ }
+ switch n := value.(type) {
+ case int:
+ if n > 0 {
+ return n, nil
+ }
+ case int64:
+ if n > 0 && n <= math.MaxInt {
+ return int(n), nil
+ }
+ case float64:
+ if n > 0 && n == math.Trunc(n) && n <= math.MaxInt {
+ return int(n), nil
+ }
+ }
+ return 0, fmt.Errorf("top_k must be a positive integer")
+}
+
+func thresholdDirection(value any) (string, error) {
+ if value == nil {
+ return "greater", nil
+ }
+ direction, ok := value.(string)
+ if !ok || (direction != "greater" && direction != "less") {
+ return "", fmt.Errorf("threshold_direction must be greater or less")
+ }
+ return direction, nil
+}
+
+func better(left, right similarityMatch, direction string) bool {
+ if left.similarity != right.similarity {
+ if direction == "less" {
+ return left.similarity < right.similarity
+ }
+ return left.similarity > right.similarity
+ }
+ if left.id != right.id {
+ return left.id < right.id
+ }
+ return left.smiles < right.smiles
+}
+
+func equalStrings(left, right []string) bool {
+ if len(left) != len(right) {
+ return false
+ }
+ for index := range left {
+ if left[index] != right[index] {
+ return false
+ }
+ }
+ return true
+}
+
+// matchHeap keeps the worst retained match at index zero.
+type matchHeap struct {
+ items []similarityMatch
+ direction string
+}
+
+func heapPush(h *matchHeap, value similarityMatch) {
+ h.items = append(h.items, value)
+ for child := len(h.items) - 1; child > 0; {
+ parent := (child - 1) / 2
+ if !better(h.items[parent], h.items[child], h.direction) {
+ break
+ }
+ h.items[parent], h.items[child] = h.items[child], h.items[parent]
+ child = parent
+ }
+}
+
+func heapDown(h *matchHeap, parent int) {
+ for {
+ child := parent*2 + 1
+ if child >= len(h.items) {
+ return
+ }
+ if right := child + 1; right < len(h.items) && better(h.items[child], h.items[right], h.direction) {
+ child = right
+ }
+ if !better(h.items[parent], h.items[child], h.direction) {
+ return
+ }
+ h.items[parent], h.items[child] = h.items[child], h.items[parent]
+ parent = child
+ }
+}
diff --git a/coordinator/internal/reducer/similarity_search_test.go b/coordinator/internal/reducer/similarity_search_test.go
new file mode 100644
index 0000000..ff4edc8
--- /dev/null
+++ b/coordinator/internal/reducer/similarity_search_test.go
@@ -0,0 +1,41 @@
+package reducer
+
+import (
+ "io"
+ "strings"
+ "testing"
+)
+
+func TestReduceSimilaritySearchKeepsExactCrossShardRanking(t *testing.T) {
+ first := strings.NewReader("rank,chembl_id,canonical_smiles,similarity\n1,B,CCC,0.50000048\n2,C,CCCC,0.1\n")
+ second := strings.NewReader("rank,chembl_id,canonical_smiles,similarity\n1,A,CC,0.50000049\n")
+
+ output, err := ReduceSimilaritySearch([]io.Reader{first, second}, map[string]any{"top_k": 2})
+ if err != nil {
+ t.Fatal(err)
+ }
+ want := "rank,chembl_id,canonical_smiles,similarity\n1,A,CC,0.500000\n2,B,CCC,0.500000\n"
+ if string(output) != want {
+ t.Fatalf("output = %q, want %q", output, want)
+ }
+}
+
+func TestReduceSimilaritySearchSupportsLeastSimilarDirection(t *testing.T) {
+ partial := strings.NewReader("rank,chembl_id,canonical_smiles,similarity\n1,A,CC,0.1\n2,B,CCC,0.8\n")
+ output, err := ReduceSimilaritySearch([]io.Reader{partial}, map[string]any{
+ "top_k": 1, "threshold_direction": "less",
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ if got, want := string(output), "rank,chembl_id,canonical_smiles,similarity\n1,A,CC,0.100000\n"; got != want {
+ t.Fatalf("output = %q, want %q", got, want)
+ }
+}
+
+func TestReduceSimilaritySearchRejectsMalformedPartial(t *testing.T) {
+ partial := strings.NewReader("rank,chembl_id,canonical_smiles,similarity\n2,A,CC,0.1\n")
+ if _, err := ReduceSimilaritySearch([]io.Reader{partial}, nil); err == nil {
+ t.Fatal("expected malformed rank error")
+ }
+}
diff --git a/coordinator/internal/storage/postgres/integration_test.go b/coordinator/internal/storage/postgres/integration_test.go
index b4b18ac..7628a50 100644
--- a/coordinator/internal/storage/postgres/integration_test.go
+++ b/coordinator/internal/storage/postgres/integration_test.go
@@ -90,6 +90,49 @@ func TestCreateJobPersistsEveryTask(t *testing.T) {
}
}
+func TestClaimReductionIsAtomic(t *testing.T) {
+ pool := testPool(t)
+ job, _ := seedJob(t, pool, 1)
+ repo := NewJobRepo(pool)
+ ctx := context.Background()
+ if err := repo.UpdateStatus(ctx, job.ID, domain.JobReducing, nil); err != nil {
+ t.Fatal(err)
+ }
+
+ var (
+ wg sync.WaitGroup
+ mu sync.Mutex
+ claimed int
+ )
+ for range 8 {
+ wg.Add(1)
+ go func() {
+ defer wg.Done()
+ ok, err := repo.ClaimReduction(context.Background(), job.ID, time.Now().UTC())
+ if err != nil {
+ t.Errorf("claim reduction: %v", err)
+ return
+ }
+ if ok {
+ mu.Lock()
+ claimed++
+ mu.Unlock()
+ }
+ }()
+ }
+ wg.Wait()
+ if claimed != 1 {
+ t.Fatalf("reducer claims = %d, want 1", claimed)
+ }
+ stored, err := repo.Get(ctx, job.ID)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if stored.Status != domain.JobReducing || stored.ReducerStartedAt == nil {
+ t.Fatalf("stored reduction state = %+v", stored)
+ }
+}
+
// A job must land whole or not at all: a half-created job leaves chunks no
// worker could ever complete.
func TestCreateJobRollsBackOnFailure(t *testing.T) {
diff --git a/coordinator/internal/storage/postgres/job_repo.go b/coordinator/internal/storage/postgres/job_repo.go
index f2a7e2e..d889a23 100644
--- a/coordinator/internal/storage/postgres/job_repo.go
+++ b/coordinator/internal/storage/postgres/job_repo.go
@@ -25,7 +25,10 @@ func NewJobRepo(pool *pgxpool.Pool) *JobRepo {
var _ usecase.JobRepository = (*JobRepo)(nil)
-var jobColumns = []string{"id", "workload", "input_uri", "parameters", "status", "created_at", "completed_at"}
+var jobColumns = []string{
+ "id", "workload", "input_uri", "parameters", "status", "created_at", "completed_at",
+ "input_artifact_id", "result_artifact_id", "error_code", "error_message", "reducer_started_at",
+}
// Insert runs inside the caller's transaction, alongside the job's tasks — that
// is what makes "all tasks or none" hold.
@@ -55,7 +58,8 @@ func (r *JobRepo) Get(ctx context.Context, id uuid.UUID) (*domain.Job, error) {
status string
)
err = conn(ctx, r.pool).QueryRow(ctx, sql, args...).Scan(
- &j.ID, &j.Workload, &j.InputURI, &j.Parameters, &status, &j.CreatedAt, &j.CompletedAt)
+ &j.ID, &j.Workload, &j.InputURI, &j.Parameters, &status, &j.CreatedAt, &j.CompletedAt,
+ &j.InputArtifactID, &j.ResultArtifactID, &j.ErrorCode, &j.ErrorMessage, &j.ReducerStartedAt)
if errors.Is(err, pgx.ErrNoRows) {
return nil, domain.ErrJobNotFound
}
@@ -66,6 +70,64 @@ func (r *JobRepo) Get(ctx context.Context, id uuid.UUID) (*domain.Job, error) {
return &j, nil
}
+func (r *JobRepo) ClaimReduction(ctx context.Context, id uuid.UUID, startedAt time.Time) (bool, error) {
+ sql, args, err := psql.Update("jobs").
+ Set("reducer_started_at", startedAt).
+ Where(sq.Eq{"id": id, "status": string(domain.JobReducing), "reducer_started_at": nil}).
+ ToSql()
+ if err != nil {
+ return false, err
+ }
+ tag, err := conn(ctx, r.pool).Exec(ctx, sql, args...)
+ if err != nil {
+ return false, err
+ }
+ return tag.RowsAffected() == 1, nil
+}
+
+func (r *JobRepo) CompleteWithResult(ctx context.Context, id, resultArtifactID uuid.UUID, completedAt time.Time) error {
+ sql, args, err := psql.Update("jobs").
+ SetMap(map[string]any{
+ "status": string(domain.JobCompleted),
+ "result_artifact_id": resultArtifactID,
+ "completed_at": completedAt,
+ "reducer_started_at": nil,
+ "error_code": nil,
+ "error_message": nil,
+ }).
+ Where(sq.Eq{"id": id, "status": string(domain.JobReducing)}).
+ ToSql()
+ if err != nil {
+ return err
+ }
+ tag, err := conn(ctx, r.pool).Exec(ctx, sql, args...)
+ if err != nil {
+ return err
+ }
+ if tag.RowsAffected() == 0 {
+ return domain.ErrJobNotFound
+ }
+ return nil
+}
+
+func (r *JobRepo) FailReduction(ctx context.Context, id uuid.UUID, code, message string, completedAt time.Time) error {
+ sql, args, err := psql.Update("jobs").
+ SetMap(map[string]any{
+ "status": string(domain.JobFailed),
+ "completed_at": completedAt,
+ "error_code": code,
+ "error_message": message,
+ "reducer_started_at": nil,
+ }).
+ Where(sq.Eq{"id": id, "status": string(domain.JobReducing)}).
+ ToSql()
+ if err != nil {
+ return err
+ }
+ _, err = conn(ctx, r.pool).Exec(ctx, sql, args...)
+ return err
+}
+
func (r *JobRepo) UpdateStatus(ctx context.Context, id uuid.UUID,
status domain.JobStatus, completedAt *time.Time) error {
diff --git a/coordinator/internal/transport/http/dto.go b/coordinator/internal/transport/http/dto.go
index f944482..42e706d 100644
--- a/coordinator/internal/transport/http/dto.go
+++ b/coordinator/internal/transport/http/dto.go
@@ -119,6 +119,8 @@ type jobProgressResponse struct {
Done int `json:"completed"`
Failed int `json:"failed"`
Cancelled int `json:"cancelled"`
+ ResultURI string `json:"result_uri,omitempty"`
+ ErrorCode string `json:"error_code,omitempty"`
}
type uploadArtifactResponse struct {
@@ -153,7 +155,7 @@ func toClaimedTaskResponse(c domain.ClaimedTask) claimedTaskResponse {
}
func toJobProgressResponse(p domain.JobProgress) jobProgressResponse {
- return jobProgressResponse{
+ out := jobProgressResponse{
ID: p.Job.ID,
Status: string(p.DeriveStatus()),
Total: p.Total,
@@ -163,4 +165,11 @@ func toJobProgressResponse(p domain.JobProgress) jobProgressResponse {
Failed: p.Failed,
Cancelled: p.Cancelled,
}
+ if p.Job.ResultArtifactID != nil && out.Status == string(domain.JobCompleted) {
+ out.ResultURI = "/jobs/" + p.Job.ID.String() + "/result"
+ }
+ if p.Job.ErrorCode != nil {
+ out.ErrorCode = *p.Job.ErrorCode
+ }
+ return out
}
diff --git a/coordinator/internal/transport/http/dto_test.go b/coordinator/internal/transport/http/dto_test.go
new file mode 100644
index 0000000..3fb7608
--- /dev/null
+++ b/coordinator/internal/transport/http/dto_test.go
@@ -0,0 +1,25 @@
+package http
+
+import (
+ "testing"
+
+ "github.com/google/uuid"
+
+ "github.com/emil28092005/SciMesh/coordinator/internal/domain"
+)
+
+func TestJobProgressResponseExposesFinalResultOnlyWhenCompleted(t *testing.T) {
+ id := uuid.New()
+ result := uuid.New()
+ progress := domain.JobProgress{Job: domain.Job{
+ ID: id, Status: domain.JobCompleted, ResultArtifactID: &result,
+ }, Total: 1, Done: 1}
+ if got, want := toJobProgressResponse(progress).ResultURI, "/jobs/"+id.String()+"/result"; got != want {
+ t.Fatalf("result URI = %q, want %q", got, want)
+ }
+
+ progress.Job.Status = domain.JobReducing
+ if got := toJobProgressResponse(progress).ResultURI; got != "" {
+ t.Fatalf("reducing job exposes result URI %q", got)
+ }
+}
diff --git a/coordinator/internal/transport/http/handlers.go b/coordinator/internal/transport/http/handlers.go
index 33ad06b..e8c827b 100644
--- a/coordinator/internal/transport/http/handlers.go
+++ b/coordinator/internal/transport/http/handlers.go
@@ -150,6 +150,12 @@ func (s *Server) handleResult(w http.ResponseWriter, r *http.Request) {
s.writeError(w, r, err)
return
}
+ if s.uc.ReduceJob != nil {
+ if err := s.uc.ReduceJob.Execute(ctx, task.JobID); err != nil {
+ s.writeError(w, r, err)
+ return
+ }
+ }
writeJSON(w, http.StatusOK, taskResponse{ID: task.ID, JobID: task.JobID, Status: string(task.Status)})
}
@@ -399,6 +405,25 @@ func (s *Server) handleGetJob(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, toJobProgressResponse(progress))
}
+func (s *Server) handleGetJobResult(w http.ResponseWriter, r *http.Request) {
+ ctx, cancel := s.reqCtx(r)
+ defer cancel()
+ jobID, ok := s.pathUUID(w, r, "job_id")
+ if !ok {
+ return
+ }
+ art, body, err := s.uc.GetJobResult.Execute(ctx, jobID)
+ if err != nil {
+ s.writeError(w, r, err)
+ return
+ }
+ defer func() { _ = body.Close() }()
+ w.Header().Set("Content-Type", art.ContentType)
+ w.Header().Set("Content-Length", strconv.FormatInt(art.SizeBytes, 10))
+ w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", art.Filename))
+ _, _ = io.Copy(w, body)
+}
+
// handleCancelJob stops all non-terminal shards for an operator-requested job.
// It is available to both the bearer API and the separately authenticated UI.
func (s *Server) handleCancelJob(w http.ResponseWriter, r *http.Request) {
diff --git a/coordinator/internal/transport/http/server.go b/coordinator/internal/transport/http/server.go
index 00e80d8..560c85b 100644
--- a/coordinator/internal/transport/http/server.go
+++ b/coordinator/internal/transport/http/server.go
@@ -22,8 +22,10 @@ type UseCases struct {
ClaimTask *usecase.ClaimTask
RenewLease *usecase.RenewLease
CompleteTask *usecase.CompleteTask
+ ReduceJob *usecase.ReduceJob
FailTask *usecase.FailTask
GetJobStatus *usecase.GetJobStatus
+ GetJobResult *usecase.GetJobResult
CancelJob *usecase.CancelJob
UploadArtifact *usecase.UploadArtifact
DownloadArtifact *usecase.DownloadArtifact
@@ -62,6 +64,7 @@ func (s *Server) Handler(token string, uiToken ...string) http.Handler {
protected.HandleFunc("POST /jobs", s.handleCreateJob)
protected.HandleFunc("POST /jobs/upload", s.handleUploadDataset)
protected.HandleFunc("GET /jobs/{job_id}", s.handleGetJob)
+ protected.HandleFunc("GET /jobs/{job_id}/result", s.handleGetJobResult)
protected.HandleFunc("POST /jobs/{job_id}/cancel", s.handleCancelJob)
protected.HandleFunc("POST /tasks/claim", s.handleClaim)
protected.HandleFunc("GET /tasks/{task_id}/input", s.handleGetTaskInput)
diff --git a/coordinator/internal/transport/http/server_test.go b/coordinator/internal/transport/http/server_test.go
index 52c453f..97c7681 100644
--- a/coordinator/internal/transport/http/server_test.go
+++ b/coordinator/internal/transport/http/server_test.go
@@ -42,6 +42,7 @@ func newEnvWithUIToken(t *testing.T, ready func(context.Context) error, configur
clk := memstore.NewClock(time.Date(2026, 7, 21, 12, 0, 0, 0, time.UTC))
tx := memstore.Tx{}
lease := 2 * time.Minute
+ downloadArtifact := usecase.NewDownloadArtifact(arts, blobs)
uc := coordhttp.UseCases{
RegisterWorker: usecase.NewRegisterWorker(work, clk),
@@ -50,11 +51,13 @@ func newEnvWithUIToken(t *testing.T, ready func(context.Context) error, configur
ClaimTask: usecase.NewClaimTask(tasks, jobs, work, tx, clk, lease),
RenewLease: usecase.NewRenewLease(tasks, work, tx, clk, lease),
CompleteTask: usecase.NewCompleteTask(tasks, jobs, arts, tx, clk),
+ ReduceJob: usecase.NewReduceJob(jobs, tasks, arts, blobs, tx, clk),
FailTask: usecase.NewFailTask(tasks, jobs, tx, clk),
GetJobStatus: usecase.NewGetJobStatus(jobs, tasks),
+ GetJobResult: usecase.NewGetJobResult(jobs, downloadArtifact),
CancelJob: usecase.NewCancelJob(jobs, tasks, tx, clk),
UploadArtifact: usecase.NewUploadArtifact(tasks, arts, blobs, tx, clk),
- DownloadArtifact: usecase.NewDownloadArtifact(arts, blobs),
+ DownloadArtifact: downloadArtifact,
GetTaskInput: usecase.NewGetTaskInput(tasks, arts, blobs),
Dashboard: usecase.NewDashboard(memstore.NewUIReadRepo(jobs, tasks, work, arts)),
}
@@ -400,6 +403,42 @@ func TestFullLifecycle(t *testing.T) {
}
}
+func TestSimilaritySearchLifecyclePublishesFinalResult(t *testing.T) {
+ e := newEnv(t, healthy)
+ code, job := e.uploadDataset(t, "similarity-search", 10, "chembl_id\tcanonical_smiles\nA\tCC\n")
+ if code != http.StatusCreated {
+ t.Fatalf("upload job: %d", code)
+ }
+ jobID := job["job_id"].(string)
+ code, claim := e.do(t, "POST", "/tasks/claim", `{"worker_id":"w1","capabilities":["similarity-search"]}`)
+ if code != http.StatusOK {
+ t.Fatalf("claim: %d", code)
+ }
+ taskID := claim["task_id"].(string)
+ attempt := int(claim["attempt"].(float64))
+ artifactID := e.putArtifact(t, taskID, "w1", attempt, "rank,chembl_id,canonical_smiles,similarity\n1,A,CC,0.900000\n")
+ if code, _ := e.do(t, "POST", "/tasks/"+taskID+"/result",
+ `{"worker_id":"w1","attempt":`+itoa(attempt)+`,"result":{"artifact_id":"`+artifactID+`"}}`); code != http.StatusOK {
+ t.Fatalf("complete: %d", code)
+ }
+
+ code, progress := e.do(t, "GET", "/jobs/"+jobID, "")
+ if code != http.StatusOK || progress["status"] != "completed" || progress["result_uri"] != "/jobs/"+jobID+"/result" {
+ t.Fatalf("progress = (%d, %v)", code, progress)
+ }
+ req, _ := http.NewRequestWithContext(context.Background(), "GET", e.ts.URL+progress["result_uri"].(string), nil)
+ req.Header.Set("Authorization", "Bearer "+token)
+ resp, err := http.DefaultClient.Do(req)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer resp.Body.Close()
+ body, _ := io.ReadAll(resp.Body)
+ if resp.StatusCode != http.StatusOK || string(body) != "rank,chembl_id,canonical_smiles,similarity\n1,A,CC,0.900000\n" {
+ t.Fatalf("final result = (%d, %q)", resp.StatusCode, body)
+ }
+}
+
func TestForeignArtifactResultConflict(t *testing.T) {
e := newEnv(t, healthy)
e.do(t, "POST", "/jobs", `{"workload":"w","input_uri":"s3://in",
diff --git a/coordinator/internal/transport/http/templates/dashboard.html b/coordinator/internal/transport/http/templates/dashboard.html
index 5e24f90..e643cc7 100644
--- a/coordinator/internal/transport/http/templates/dashboard.html
+++ b/coordinator/internal/transport/http/templates/dashboard.html
@@ -12,7 +12,7 @@
Local coordinator
SciMesh operator dashboard
See where a computation is and what should happen next.
Start a check
- This screen currently diagnoses shard jobs.Workers upload partial CSVs to the coordinator. Until a reducer is implemented, those files are not one final scientific result.
1. Upload TSVThe coordinator splits the file into shard tasks.
2. Wait for a workerA worker claims a shard, calculates similarity, and returns a CSV.
3. Inspect artifactsDownload a partial result from the job page.
+ Similarity-search jobs produce a final CSV.Workers return shard-level candidates; after every shard succeeds, the coordinator deterministically merges them into one global top-k result.
1. Upload TSVThe coordinator splits the file into shard tasks.
2. Wait for a workerA worker claims a shard, calculates similarity, and returns a CSV.
3. Download resultWhen merging finishes, download the final CSV from the job page.
No jobs yet. Click “Start a check”, upload a small TSV, and leave a worker running.
{{end}}
Workers
diff --git a/coordinator/internal/transport/http/templates/job.html b/coordinator/internal/transport/http/templates/job.html
index b985a2e..596a12d 100644
--- a/coordinator/internal/transport/http/templates/job.html
+++ b/coordinator/internal/transport/http/templates/job.html
@@ -13,14 +13,14 @@
← Back to jobs
{{workloadLabel .Workload}}
Execution progress
{{statusLabel .Status}}
{{statusHint .Status}}
{{if cancellable .Status}}This cancels every shard that is not finished yet.{{else}}Summary refreshes automatically every two seconds.{{end}}
- What can be downloaded now? partial_result files come from individual shards. They are useful for checking the pipeline, but are not a merged final CSV because the reducer is not implemented yet.
+ {{if .FinalResultAvailable}}Final result ready Download the final_result CSV below. It is the deterministic global top-k across all completed shards. The partial_result files remain available for diagnostics.{{else if eq .Status "reducing"}}Merging completed shards Every shard has finished. The coordinator is building one deterministic global CSV; refresh in a moment to download it.{{else}}What can be downloaded now? partial_result files come from individual shards. They are useful for checking the pipeline, but are not a merged final CSV yet.{{end}}
Shard tasks
If a task fails, its code and message appear here. Refresh the page to update the detailed rows.
Shard
State
Attempt
Worker / lease
Error
{{range .Tasks}}
#{{.ChunkIndex}}
{{statusLabel .Status}}
{{.Attempt}} / {{.MaxAttempts}}
{{if .LeaseOwner}}{{.LeaseOwner}}{{if .LeaseExpiresAt}} until {{time .LeaseExpiresAt}}{{end}}{{else}}—{{end}}