Skip to content

feat: add federated analytics demo - #10

Open
uniqueg wants to merge 7 commits into
mainfrom
feat/5-federated-analytics
Open

uniqueg wants to merge 7 commits into
mainfrom
feat/5-federated-analytics

Conversation

@uniqueg

@uniqueg uniqueg commented Nov 13, 2026 •

Copy link
Copy Markdown
Member

Summary

Adds the demo demos/federated-analytics: the same analysis at two Nodes through GA4GH TES, directly or through a proTES gateway, with the results combined. Only aggregates leave the Nodes, never the rows.

Stacked on #9 (test data, #6): until #9 is merged, this diff also shows its commits. Merge #9 first.

  • local_stats.py (stdlib only) runs inside each task and prints the count, sum and sum of squares per condition and column.
  • federated_analytics.py sends one TES task per partition of data/manifest.json. The script goes in as a content input and the partition as a URL input. The client reads the result from the executor's stdout in the task logs, so no object store is needed. It combines the results into mean, SD and Welch's t.
    • --tes URL once sends everything through a gateway; once per partition sends each partition to its own Node.
    • TES_TOKEN is sent as a bearer token.
    • --local runs without any services.
    • Request and task failures end with a message and exit code 1.
  • The demo README covers how it works, the quick start, how to run it against services, its requirements, and what has and hasn't been run. It is listed in the root README.
  • Tests show that the federated result equals the pooled result. A fake in-memory TES service covers gateway and per-Node routing, failures and timeouts. A test also runs the README's quick start as a script, so CI runs the quick start on every change.

Closes #5

Verification

  • uv run pytest: 59 passed, 100% coverage
  • uv run ruff check ., uv run ruff format --check . and uv run mypy: clean
  • python demos/federated-analytics/federated_analytics.py --local: prints the table with Welch's t (treated vs control), od600: 13.81 and expression: 8.48
  • Not run live: against a deployed TES service or proTES gateway with tokens, because I can't reach such a deployment from here. The README says so and explains how to run it.
  • CI: Lint, Test and CI passed green

Review

adversarial-reviewer and quality-reviewer, 2 rounds.

  • Round 1 fixes: the --tes count must be 1 or the number of partitions; new --poll and --timeout options; the deadline uses a monotonic clock that includes request time; HTTP and network errors become a message and exit code 1; the test injects a transport instead of monkeypatching; the ANN401 exception is limited to the demo and its tests.
  • Round 2 fixes: timeouts, connection errors and non-JSON bodies are wrapped too; --poll and --timeout must be positive and finite; the count test is stricter.
  • Sourcery fixes: the token is sent only over HTTPS (plain HTTP only to localhost); redirects are refused; malformed TES responses become errors; sent tasks are cancelled on failure; --json is strict JSON (null instead of NaN); empty partitions and non-finite values are rejected; local_stats.py errors name the row and column only, never values, so no data leak through task logs.

Declined review findings

  • The timeout restarts for each task, which can add up over several partitions: the tasks run in parallel on the services, and the README documents the timeout as per task.
  • Results come from executor stdout only, and some TES backends may truncate it or leave it out: the demo then fails loudly ("no executor output"). The README lists stdout in the task logs as a requirement.
  • _http (the real network call) has no test: it is a thin urlopen wrapper, and everything around it is tested through the transport.
  • From Sourcery (reasons in the threads): a hard deadline for --timeout; revision-pinned URLs and checksum checks inside the tasks; staged data generation; Welford-style variance (count, mean, M2), a good follow-up if the demo grows.

Note for the maintainer

  • The issue asks for the demo to be documented in the hub, which is the elixir-cloud-aai/docs repo. An issue for that is drafted and will be posted separately.

🤖 Generated with Claude Code

uniqueg and others added 6 commits November 13, 2026 21:25
Add one CSV partition per Node of a synthetic data set, a manifest
with URLs, sizes and checksums, and the seeded generator that writes
them. Tests check that the committed files match the generator. Add
CI (lint, test, gate) for the Python code of the repository.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Keep LF endings of the test data, write the manifest with LF, say that
the URLs follow main, and test the documented difference between the
partitions.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Run local summary statistics as one TES task per partition, directly
or through a proTES gateway, read the aggregates from the task logs and
combine them into the statistics of the whole data set. --local runs
without services; tests use a fake TES service.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Accept --tes once or once per partition, add --poll and --timeout,
count request time against the timeout, turn HTTP and network errors
into a message and exit code 1, and limit the ANN401 exception to the
demo and its tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Turn timeouts, connection errors and non-JSON bodies into task errors,
and accept only positive, finite --poll and --timeout values.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@sourcery-ai

sourcery-ai Bot commented Nov 13, 2026 •

Copy link
Copy Markdown

Reviewer's Guide

Data structures

Add reproducible synthetic Node partitions and a manifest containing their locations, sizes, and checksums for the federated demo and its tests.

  • data/manifest.json L1-31
  • data/synthetic-growth/node-a.csv L1-51
  • data/synthetic-growth/node-b.csv L1-51
  • scripts/generate_test_data.py L1-168

API

Define the local-statistics and federated-analytics entry points, TES task and transport contract, CLI options, and aggregate-result formats.

  • demos/federated-analytics/federated_analytics.py L1-530
  • demos/federated-analytics/local_stats.py L1-95

Configuration

Set up the Python project and locked development dependencies, CI lint/test/gate workflow, and repository file-handling rules.

  • .gitattributes L1-2
  • .github/workflows/ci.yml L1-93
  • .gitignore L183
  • pyproject.toml L1-43
  • uv.lock L1-487

Documentation

Explain the synthetic data and federated demo, their service requirements and quick start, and link the demo and CI instructions from the root README.

  • README.md L28-35, L57-66, L99
  • data/README.md L1-55
  • demos/federated-analytics/README.md L1-104

Tests

Check generated-data reproducibility, pooled-versus-partitioned analytics, TES client behavior and failures, and the documented local quick start.

  • tests/test_federated_analytics.py L1-456
  • tests/test_generate_test_data.py L1-93

Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

@uniqueg
uniqueg marked this pull request as ready for review November 13, 2026 20:50
@sourcery-ai

sourcery-ai Bot commented Nov 13, 2026 •

Copy link
Copy Markdown

Linked issues

Checked at commit 055983e

#5 demo: federated analytics · 1 of 3 met · 1 to check by hand

# Requirement Status Notes
1 Run the same analysis at two Nodes through the gateway and combine the results. ✅ Met The client submits one task per partition through a single gateway client, combines the returned aggregates, and tests this flow against a fake TES service.
2 The demo runs. 👀 Check by hand (runtime) The README reports local and fake-service tests but says it has not been run against a deployed TES service or proTES gateway.
3 Document the demo in the hub. ❌ Not addressed The PR adds repository documentation, but says the hub documentation is deferred: “An issue for that is drafted and will be posted separately.”

@sourcery-ai sourcery-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey - I've found 12 issues

Prompt for AI Agents
Please address the comments from this code review:

## Individual Comments

### Comment 1
<location path="demos/federated-analytics/federated_analytics.py" line_range="221" />
<code_context>
+        if body is not None:
+            headers["Content-Type"] = "application/json"
+            data = json.dumps(body).encode()
+        if self.token:
+            headers["Authorization"] = f"Bearer {self.token}"
+        request = urllib.request.Request(url, data=data, headers=headers)
</code_context>
<issue_to_address>
**Bearer token exposed over HTTP**

When `TES_TOKEN` is set and the TES endpoint uses `http://`, `TesClient._request` adds the bearer token to the `Authorization` header and sends it unencrypted, so network observers can capture and use the token.

Reject plain-HTTP TES endpoints when a token is configured, or otherwise ensure authenticated requests use HTTPS.

Also at `demos/federated-analytics/federated_analytics.py:199-200`.
</issue_to_address>

### Comment 2
<location path="demos/federated-analytics/federated_analytics.py" line_range="182" />
<code_context>
+    Returns:
+        The decoded response body.
+    """
+    with urllib.request.urlopen(request, timeout=60) as response:  # noqa: S310
+        return json.load(response)
+
</code_context>
<issue_to_address>
**Redirect leaks bearer token**

When an authenticated TES endpoint redirects a request to a different origin, `urlopen` follows redirects with the request headers, including the `Authorization` header added by `TesClient._request`; a redirect to another origin therefore sends the bearer token to that origin.

Reject cross-origin redirects or strip the authorization header before following them.
</issue_to_address>

### Comment 3
<location path="demos/federated-analytics/federated_analytics.py" line_range="273" />
<code_context>
+        while True:
+            task = self._request(f"{self.url}/{task_id}?view=FULL")
+            state = task.get("state", "UNKNOWN")
+            if state in TERMINAL_STATES:
+                break
+            if clock() >= deadline:
</code_context>
<issue_to_address>
**Task wait exceeds configured timeout**

When A status request or poll interval takes longer than the remaining timeout, `TesClient.wait` accepts terminal states before checking the deadline, sleeps for the full poll interval, and `_http` can block for its fixed 60-second timeout; the caller therefore waits past the configured timeout or sees success after it expires.

Check the deadline before accepting terminal states, cap each sleep and request timeout by the remaining budget, and fail when the deadline expires.

Also at `demos/federated-analytics/federated_analytics.py:182`, `demos/federated-analytics/federated_analytics.py:275-278`.
</issue_to_address>

### Comment 4
<location path="demos/federated-analytics/federated_analytics.py" line_range="243" />
<code_context>
+        Returns:
+            The ID of the task.
+        """
+        return str(self._request(self.url, task)["id"])
+
+    def wait(
</code_context>
<issue_to_address>
**Malformed TES response causes traceback**

When A TES endpoint returns valid JSON with a missing task ID or a non-object response, `TesClient.submit` indexes the response for `id`, and `TesClient.wait` calls `.get()` without validating the response shape; `KeyError` or `AttributeError` escapes `main`'s `TaskError` handling, so the caller sees a traceback.

Validate TES response shapes and required fields, and raise `TaskError` for invalid responses.

Also at `demos/federated-analytics/federated_analytics.py:272`, `demos/federated-analytics/federated_analytics.py:318`.
</issue_to_address>

### Comment 5
<location path="demos/federated-analytics/local_stats.py" line_range="45" />
<code_context>
+            group = row[GROUP_COLUMN]
+            values = {col: float(row[col]) for col in VALUE_COLUMNS}
+        except (KeyError, TypeError, ValueError) as exc:
+            raise ValueError(f"invalid row {row!r}: {exc}") from exc
+        per_group = stats.setdefault(group, {})
+        for col, value in values.items():
</code_context>
<issue_to_address>
**Malformed rows escape in task logs**

When a partition contains a row with a missing required field or a non-numeric value, `summarise` embeds the full row in its `ValueError`, and `main` leaves that exception uncaught. A malformed input therefore puts raw row contents in the executor traceback and TES stderr logs, sending data off the Node despite the claim that only aggregates leave it.

Report only a row index and field-level error, without including row values or chained conversion errors in task logs.
</issue_to_address>

### Comment 6
<location path="data/manifest.json" line_range="17" />
<code_context>
+        {
+          "node": "node-a",
+          "path": "synthetic-growth/node-a.csv",
+          "url": "https://raw.githubusercontent.com/elixir-cloud-aai/elixir-cloud-demos/main/data/synthetic-growth/node-a.csv",
+          "sha256": "425161d3670348f82b5fa1144dc9528b236c5a02fab4e609d5bbcd02fcff0f0f",
+          "size": 1626
</code_context>
<issue_to_address>
**Tasks can mix dataset revisions**

When the partition files on `main` change after the manifest is loaded or between task input downloads, `make_task` passes mutable `main` URLs to TES without verifying the manifest checksum, so tasks can summarize different dataset revisions and `combine` reports a hybrid result as successful.

Use immutable, revision-pinned input URLs for every task and verify the downloaded inputs against the manifest checksums.

Also at `demos/federated-analytics/federated_analytics.py:161`.
</issue_to_address>

### Comment 7
<location path="scripts/generate_test_data.py" line_range="112-114" />
<code_context>
+    for node in NODES:
+        rel_path = f"{DATASET}/{node}.csv"
+        content = _to_csv(make_rows(node=node))
+        target = out_dir / rel_path
+        target.parent.mkdir(parents=True, exist_ok=True)
+        target.write_bytes(content)
+        files.append(
+            {
</code_context>
<issue_to_address>
**Readers see partial dataset updates**

When a demo reads the data while generation is in progress, or generation stops after overwriting only some files, `write_data_sets` overwrites partition files in place and writes the manifest only after both files; a concurrent demo can read a truncated or mixed set of partitions and return incorrect statistics, or a failed generation can leave files inconsistent with the old manifest.

Write the complete dataset and manifest to a staging location, then publish them atomically as a unit.
</issue_to_address>

### Comment 8
<location path="demos/federated-analytics/local_stats.py" line_range="41-52" />
<code_context>
+        if self.token:
+            headers["Authorization"] = f"Bearer {self.token}"
+        request = urllib.request.Request(url, data=data, headers=headers)
+        try:
+            return self.transport(request)
+        except urllib.error.HTTPError as exc:
</code_context>
<issue_to_address>
**Empty partitions produce successful incomplete results**

When A partition contains only a header or no data rows, `local_stats.summarise` returns an empty mapping, which `combine` preserves or skips; the CLI returns success with an empty result or one that omits a Node's statistics.

Reject empty partitions or report them as an error instead of returning successful incomplete results.

Also at `demos/federated-analytics/federated_analytics.py:81-90`, `demos/federated-analytics/federated_analytics.py:105`, `demos/federated-analytics/federated_analytics.py:423-424`.
</issue_to_address>

### Comment 9
<location path="demos/federated-analytics/federated_analytics.py" line_range="423" />
<code_context>
+            print(f"Error: {exc}", file=sys.stderr)
+            return 1
+    result = combine(partials)
+    print(json.dumps(result, indent=2, sort_keys=True) if args.json else report(result))
+    return 0
+
</code_context>
<issue_to_address>
**JSON output contains invalid NaN**

When A group and column have exactly one observation, `combine` assigns `math.nan` as the standard deviation, and `json.dumps` emits `NaN`, which strict JSON parsers reject.

Represent an undefined standard deviation with a JSON-valid value, such as `null`, before serializing the result.

Also at `demos/federated-analytics/federated_analytics.py:99`.
</issue_to_address>

### Comment 10
<location path="demos/federated-analytics/federated_analytics.py" line_range="310-315" />
<code_context>
+    submitted = []
+    for idx, part in enumerate(partitions):
+        client = clients[idx % len(clients)]
+        task_id = client.submit(make_task(part, script, image))
+        print(f"Submitted {part['node']} to {client.url}: {task_id}", file=sys.stderr)
+        submitted.append((client, task_id))
+    partials = []
+    for client, task_id in submitted:
+        stdout = client.wait(task_id, **wait_kwargs)
+        try:
+            partials.append(json.loads(stdout.strip().splitlines()[-1]))
</code_context>
<issue_to_address>
**Failed runs leave tasks running**

When a submission or task fails after at least one other task has been submitted, `run_tes` submits every task before waiting; when a later submission or wait fails, it raises without cancelling the other submitted tasks, so the caller exits with an error while those tasks continue consuming service resources.

On failure, cancel outstanding submitted tasks where supported, or otherwise provide an explicit cleanup path and identify the remaining task IDs.
</issue_to_address>

### Comment 11
<location path="demos/federated-analytics/local_stats.py" line_range="51" />
<code_context>
+            agg = per_group.setdefault(col, {"n": 0.0, "sum": 0.0, "sumsq": 0.0})
+            agg["n"] += 1
+            agg["sum"] += value
+            agg["sumsq"] += value * value
+    return stats
+
</code_context>
<issue_to_address>
**Variance loses precision**

When numeric values have a large baseline relative to their spread, the sum-of-squares calculation subtracts two nearly equal large values, so floating-point rounding can reduce a real variance to zero and produce incorrect standard deviations and Welch statistics.

Accumulate and merge numerically stable variance statistics, such as count, mean, and M2, instead of deriving variance from sums and sums of squares.

Also at `demos/federated-analytics/federated_analytics.py:95-97`.
</issue_to_address>

### Comment 12
<location path="demos/federated-analytics/local_stats.py" line_range="43" />
<code_context>
+    for row in rows:
+        try:
+            group = row[GROUP_COLUMN]
+            values = {col: float(row[col]) for col in VALUE_COLUMNS}
+        except (KeyError, TypeError, ValueError) as exc:
+            raise ValueError(f"invalid row {row!r}: {exc}") from exc
</code_context>
<issue_to_address>
**Non-finite measurements pass validation**

When a CSV value is `nan` or `inf`, `local_stats.summarise` accepts `nan` and `inf` because `float()` parses them without raising; aggregation then produces meaningless results, and `--json` can emit non-standard JSON constants.

Reject values for which `math.isfinite()` is false before adding them to the aggregates.
</issue_to_address>

Sourcery assessment

Approval pending. 12 findings to address first.

Blocking findings: demos/federated-analytics/federated_analytics.py:221, demos/federated-analytics/federated_analytics.py:182, demos/federated-analytics/federated_analytics.py:273, demos/federated-analytics/federated_analytics.py:243, demos/federated-analytics/local_stats.py:45, and 7 more


Sourcery is free for open source - if you like our reviews please consider sharing them ✨

Send tokens only over HTTPS, refuse redirects, validate TES responses,
cancel sent tasks on failure, emit strict JSON, and reject empty
partitions and non-finite values with errors that name no data values.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

demo: federated analytics

1 participant