Skip to content

fix(bench): exit-2 on no-data + SKIP verdict in band-check (#479) - #485

Merged
robotrocketscience merged 3 commits into
mainfrom
feat/issue-479-bench-3state-exit
May 8, 2026
Merged

fix(bench): exit-2 on no-data + SKIP verdict in band-check (#479)#485
robotrocketscience merged 3 commits into
mainfrom
feat/issue-479-bench-3state-exit

Conversation

@robotrocketscience

@robotrocketscience robotrocketscience commented May 8, 2026

Copy link
Copy Markdown
Owner

Closes #479.

Implements the 3-state exit-code contract producer side, plus the band-check consumer side. The transport layer (benchmarks/run.py::run_invocation) was already 3-state aware from PR #465's gate commit; this PR closes the loop.

What lands (3 atomic commits)

  1. fix(bench/adapters) — uniform pattern across 6 adapters: try/except FileNotFoundError around the loader + post-load empty-list guard, both routing to sys.stderr + sys.exit(2).

  2. fix(bench/tolerance) — new Verdict.SKIP enum member. _ancestor_skipped() walks the observed sub-tree; check_report() consults it before the existing missing-leaf FAIL branch. summarize() tallies SKIP separately and never promotes to FAIL/WARN.

  3. test(bench) — adapter exit-code coverage (10 cases, both FileNotFoundError and empty-list shapes for each of the 6 adapters; optional deps stubbed at sys.modules) + tolerance SKIP coverage (6 cases including FAIL-still-dominates and sibling-adapter isolation).

Why this resolves the surfaced incident

Before this PR: /tmp/StructMemEval empty → adapter exits 0 with no output file → dispatcher logs "adapter exited 0 but did not write $TMPDIR/T/structmemeval_<task>.json". Same surface as a real adapter bug.

After this PR: empty data → adapter exits 2 → dispatcher classifies as _status: skipped_data_missing → tolerance treats nested canonical leaves as Verdict.SKIP → CI rollup stays PASS. Real adapter crashes (exit 1) still surface as FAIL.

Acceptance crosscheck (vs issue body)

  • All five adapters updated (six counting mab_entity_index which shares the load path) — sys.exit(2) on no-data.
  • Dispatcher already reads exit code per the 2026-05-06 contract; this PR doesn't touch run.py because it was already 3-state aware (PR feat(bench): aelf bench all reproducibility harness (#437) #465 / run.py:154-160).
  • Canonical JSON _status already gets skipped_data_missing at dispatcher level — verified by existing test_skipped_data_missing_propagates.
  • Band-check workflow: skipped_data_missing does NOT count as a regression — implemented as Verdict.SKIP + summarize() rollup behaviour.
  • Stub-adapter exit-code 0/1/2/137 classification tests in test_bench_dispatcher.py already cover the dispatcher path.
  • Per-adapter tests in test_bench_adapters_exit_code.py cover the producer half (the new addition in this PR).

Out of scope

  • Per-task subkey contracts (one StructMemEval task missing while another runs fine) — issue calls this out as out of scope.
  • Retry-on-skipped — out of scope per issue.

Test plan

  • uv run pytest tests/ --ignore=tests/e2e -q → 2737 passed, 41 skipped (no regressions on existing 2734 baseline).
  • uv run pytest tests/test_bench_adapters_exit_code.py tests/test_bench_tolerance_skip.py tests/test_bench_tolerance.py tests/test_bench_dispatcher.py tests/test_benchmarks_badge.py -q → 70 passed (focused).
  • First fresh-runner CI execution will exercise the producer half end-to-end. Rollback path: revert this PR; band-check reverts to FAIL-on-missing-data behaviour.

Refs

Summary by Sourcery

Implement three-state handling for benchmark adapters and band-check tolerance when benchmark data is missing.

Bug Fixes:

  • Ensure benchmark adapters exit with code 2 and emit stderr messages when their data sources are missing or produce no records.
  • Treat benchmark runs with skipped data as non-regressions by emitting SKIP verdicts instead of FAIL for uncomputable metrics.

Enhancements:

  • Extend tolerance verdicts with a SKIP state and propagate skipped_data_missing status through band checks without affecting overall PASS/WARN/FAIL rollups.

Tests:

  • Add adapter-side exit-code tests covering missing data and empty-result scenarios across all canonical adapters.
  • Add tolerance tests verifying SKIP verdict propagation, rollup behavior, and isolation from other adapters.

Per the 3-state exit-code contract ratified at PR #465 gate commit
60e1b68 and resurfaced by the StructMemEval calibration session in
issue #473: adapters that find no data (data dir missing, dataset
file absent, filters matching zero rows) now sys.exit(2) instead of
print + plain `return`.

The dispatcher (`benchmarks/run.py::run_invocation`) already classifies
exit-code 2 as `_status: skipped_data_missing` per the 2026-05-06
contract; this commit closes the producer half so the canonical JSON
distinguishes "data not installed" from "adapter crashed."

Adapters touched (uniform pattern: try/except FileNotFoundError around
the load, plus the existing post-load empty-list guard, both routing
to sys.stderr + sys.exit(2)):

- structmemeval_adapter.py — surfaced the bug via issue #473's
  /tmp/StructMemEval-empty trace.
- locomo_adapter.py — load_locomo() reads a JSON file directly.
- longmemeval_adapter.py — load_from_file() OR HuggingFace fallback.
- mab_adapter.py — load_mab_split() can FileNotFoundError on absent
  HuggingFace cache.
- mab_entity_index_adapter.py — same load_mab_split() path.
- amabench_adapter.py — HuggingFace load_dataset() typically raises
  on absent data; if it returns zero rows after filtering, also exit 2.

The dispatcher's existing fallback at run.py:167-172 (`adapter exited
0 but did not write $TMPDIR/T/<adapter>.json`) becomes a strict
"adapter bug" signal rather than masking missing-data situations.
Pairs with the adapter-side change in this branch: when an observed
sub-result has `_status: skipped_data_missing`, every canonical leaf
under that sub-result is uncomputable, not a regression. Treating
them as FAIL (today's behaviour) over-reports regressions whenever a
fresh runner doesn't have /tmp/<dataset> populated.

- New `Verdict.SKIP` enum member (`band_kind="skipped"` for matching
  BandCheck rows).
- `_ancestor_skipped(obs, path)` walks the observed sub-tree and
  returns True at the first ancestor carrying
  `_status == "skipped_data_missing"`. `check_report()` consults this
  before its existing missing-leaf FAIL path.
- `summarize()` tallies SKIP separately from PASS/WARN/FAIL and never
  promotes the rollup above PASS based on SKIP count alone.

The CI band-check workflow consumes `summarize()`'s rollup verdict, so
no workflow YAML edit is needed: skipped sub-results stop blocking
green runs, and the per-leaf SKIP rows still surface in the BandCheck
list for human review.
Two new test files:

- `tests/test_bench_adapters_exit_code.py` — exercises each of the 6
  adapters via main() with monkey-patched loaders. Both shapes from
  the issue covered: FileNotFoundError → exit 2, empty list → exit 2.
  Optional benchmark deps (nltk / datasets / tiktoken) are stubbed at
  sys.modules so the test runs in the CI's `--group dev --extra
  archive` environment without `--extra benchmarks`.

- `tests/test_bench_tolerance_skip.py` — verifies the new SKIP verdict
  on the tolerance band-check: skip propagation to all canonical
  descendants, summarize() does not promote SKIP to FAIL/WARN, FAIL
  still dominates SKIP, sibling adapters still get checked normally,
  and the SKIP path triggers only on `_status: skipped_data_missing`
  (other statuses fall through to the existing FAIL branch).

Together with the existing `test_skipped_data_missing_propagates` in
test_bench_dispatcher.py, the producer + transport + consumer halves
of the 3-state contract are all under test.
@sourcery-ai

sourcery-ai Bot commented May 8, 2026

Copy link
Copy Markdown

Reviewer's Guide

Implements the producer-side 3-state exit code contract for six benchmark adapters (exit 2 on no-data) and adds a consumer-side SKIP verdict in the band-check tolerance logic so missing data is treated as skipped rather than failed, with tests covering both behaviors.

Sequence diagram for adapter 3-state exit and tolerance SKIP handling

sequenceDiagram
    actor CI
    participant Adapter as BenchmarkAdapter
    participant Dispatcher as RunInvocation
    participant Tolerance as ToleranceCheckReport

    CI->>Adapter: Run adapter
    alt Data directory missing
        Adapter->>Adapter: Load data
        Adapter-->>Adapter: FileNotFoundError
        Adapter->>CI: exit 2 (no data)
        CI->>Dispatcher: Record exit code 2
        Dispatcher->>Dispatcher: Set _status skipped_data_missing
        Dispatcher->>Tolerance: check_report(canonical, observed)
        loop For each canonical leaf
            Tolerance->>Tolerance: _ancestor_skipped(obs_results, path)
            Tolerance-->>Tolerance: True
            Tolerance->>Tolerance: Create BandCheck with verdict SKIP
        end
        Tolerance->>Tolerance: summarize(checks)
        Tolerance-->>CI: Overall PASS (with SKIP counts)
    else Adapter crash or real regression
        Adapter->>CI: exit 1 (error)
        CI->>Dispatcher: Record exit code 1
        Dispatcher->>Dispatcher: Set failure status
        Dispatcher->>Tolerance: check_report(...)
        Tolerance-->>CI: Overall FAIL
    end
Loading

Class diagram for updated Verdict enum and tolerance functions

classDiagram
    class Verdict {
        <<enumeration>>
        PASS
        WARN
        FAIL
        SKIP
    }

    class ToleranceModule {
        +classify(canonical_results, observed_results, lower, upper) Verdict
        +_ancestor_skipped(obs_results, path) bool
        +_walk_leaves(obj, path) list~tuple~
        +check_report(canonical_report, observed_report, bands) list~BandCheck~
        +summarize(checks) tuple~Verdict, dict~
    }

    Verdict <.. ToleranceModule : uses
    ToleranceModule ..> BandCheck : creates
Loading

Flow diagram for adapter main exit codes on no data

flowchart TD
    Start([Adapter main]) --> ParseArgs[Parse CLI args]
    ParseArgs --> LoadData[Load dataset]

    LoadData --> |Success| DataLoaded
    LoadData --> |FileNotFoundError| FileNotFound

    FileNotFound --> LogMissing[Print data not found to stderr]
    LogMissing --> Exit2A[exit 2]

    DataLoaded --> CheckEmpty{Loaded collection empty?}
    CheckEmpty --> |Yes| LogEmpty[Print no data message to stderr]
    LogEmpty --> Exit2B[exit 2]

    CheckEmpty --> |No| Continue[Proceed with benchmark logic]
    Continue --> End([Generate metrics JSON and exit 0])
Loading

File-Level Changes

Change Details Files
Adapter mains now treat missing/empty data as a hard no-data condition and exit with code 2, with messages routed to stderr instead of silently succeeding or returning.
  • Wrap dataset-loading calls in try/except FileNotFoundError and on error log a descriptive message to stderr and sys.exit(2).
  • Add post-load guards that treat an empty collection from the loader as a no-data condition, printing a message to stderr and exiting with code 2.
  • Remove previous patterns where empty datasets caused early returns (exit 0) or ambiguous behavior, ensuring all six adapters follow the same 3-state-aware contract.
benchmarks/longmemeval_adapter.py
benchmarks/locomo_adapter.py
benchmarks/mab_adapter.py
benchmarks/mab_entity_index_adapter.py
benchmarks/amabench_adapter.py
benchmarks/structmemeval_adapter.py
The tolerance/band-check pipeline now models skipped data explicitly via a SKIP verdict and recognizes dispatcher-level skipped_data_missing status so that canonical leaves under skipped adapters are not treated as regressions.
  • Extend the Verdict enum with a SKIP member and update summarize() to track SKIP counts while not letting SKIP elevate the overall verdict above PASS.
  • Introduce _ancestor_skipped() to detect _status: skipped_data_missing at or above a canonical metric path in the observed results tree.
  • Update check_report() to emit BandCheck entries with verdict=SKIP and band_kind="skipped" for leaves whose observed ancestors are marked skipped_data_missing, bypassing the existing missing-leaf FAIL logic for those paths.
benchmarks/tolerance.py
Add targeted tests to lock in adapter exit-code behavior and SKIP handling in tolerance, including optional dependency stubbing for adapter imports.
  • Create tests that monkeypatch each adapter’s loader to either raise FileNotFoundError or return an empty list, invoke main() with minimal argv, and assert that SystemExit carries code 2 in all no-data cases.
  • Introduce a small helper to stub optional benchmark dependencies (nltk, datasets, tiktoken) in sys.modules so adapters can be imported in isolation during tests.
  • Add tests that validate SKIP verdict emission for skipped_data_missing sub-results, propagation from adapter-level skips to all nested metrics, interaction of SKIP with PASS/FAIL in summarize(), and isolation so SKIP on one adapter does not affect siblings or other status values.
tests/test_bench_adapters_exit_code.py
tests/test_bench_tolerance_skip.py

Assessment against linked issues

Issue Objective Addressed Explanation
#479 Update all bench adapters (mab, locomo, longmemeval, structmemeval, amabench) so that when their data discovery/loading finds no data (missing files or empty result sets), they exit with code 2 instead of returning normally with a message.
#479 Ensure the bench dispatcher (benchmarks/run.py) and canonical JSON use a 3-state contract based on adapter exit codes: classify exit 0 as ok, 2 as skipped_data_missing, and other non-zero as adapter_error, with canonical JSON _status able to be skipped_data_missing in addition to ok and error.
#479 Update band-check/tolerance workflow so that skipped_data_missing is treated as a non-regression (does not contribute to FAIL), and add tests to cover dispatcher/adapters classification of exit codes (0/1/2/137) and SKIP behavior.

Possibly linked issues


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

@coderabbitai

coderabbitai Bot commented May 8, 2026

Copy link
Copy Markdown

Warning

Rate limit exceeded

@robotrocketscience has exceeded the limit for the number of commits that can be reviewed per hour. Please wait 46 minutes and 27 seconds before requesting another review.

You’ve run out of usage credits. Purchase more in the billing tab.

⌛ How to resolve this issue?

After the wait time has elapsed, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans have higher rate limits than the trial, open-source and free plans. In all cases, we re-allow further reviews after a brief timeout.

Please see our FAQ for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: fb89381b-81b5-4c2d-a65a-365e4a7e490a

📥 Commits

Reviewing files that changed from the base of the PR and between 1f2a80c and 04cdcfb.

📒 Files selected for processing (9)
  • benchmarks/amabench_adapter.py
  • benchmarks/locomo_adapter.py
  • benchmarks/longmemeval_adapter.py
  • benchmarks/mab_adapter.py
  • benchmarks/mab_entity_index_adapter.py
  • benchmarks/structmemeval_adapter.py
  • benchmarks/tolerance.py
  • tests/test_bench_adapters_exit_code.py
  • tests/test_bench_tolerance_skip.py
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/issue-479-bench-3state-exit

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@robotrocketscience robotrocketscience added author-Kulili PR coordination mutex attn:review Needs review (PR open, awaiting reviewer) labels May 8, 2026

@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 1 issue, and left some high level feedback:

  • The new _ancestor_skipped helper currently treats the leaf itself having _status: skipped_data_missing as a match, which is useful behavior but slightly at odds with the name/docstring (“ancestor sub-result”); consider either renaming or tightening the docstring to explicitly include the leaf node as a valid skip carrier.
  • The try/except + empty-list exit(2) pattern is duplicated across multiple adapters; consider extracting a small shared helper (e.g., a load_or_exit_2(...) wrapper) to centralize the behavior and reduce future drift between adapters.
  • In the new FileNotFoundError branches (e.g., longmemeval_adapter), the error message prints args.data even when loading from HuggingFace with no explicit path, which can show None or a misleading location; it may be clearer to special-case the remote-load path and log a message that reflects the actual failing source.
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- The new `_ancestor_skipped` helper currently treats the leaf itself having `_status: skipped_data_missing` as a match, which is useful behavior but slightly at odds with the name/docstring (“ancestor sub-result”); consider either renaming or tightening the docstring to explicitly include the leaf node as a valid skip carrier.
- The try/except + empty-list exit(2) pattern is duplicated across multiple adapters; consider extracting a small shared helper (e.g., a `load_or_exit_2(...)` wrapper) to centralize the behavior and reduce future drift between adapters.
- In the new FileNotFoundError branches (e.g., `longmemeval_adapter`), the error message prints `args.data` even when loading from HuggingFace with no explicit path, which can show `None` or a misleading location; it may be clearer to special-case the remote-load path and log a message that reflects the actual failing source.

## Individual Comments

### Comment 1
<location path="benchmarks/tolerance.py" line_range="191-197" />
<code_context>
     obs_results = observed.get("results", {})
     checks: list[BandCheck] = []
     for path, cano_val in _walk_leaves(cano_results):
+        if _ancestor_skipped(obs_results, path):
+            checks.append(BandCheck(
+                path=path, canonical=cano_val, observed=float("nan"),
+                lower=cano_val, upper=cano_val, band_kind="skipped",
+                verdict=Verdict.SKIP,
+                note=(
+                    f"observed sub-result skipped (data missing) at "
+                    f"{'/'.join(path)}"
+                ),
</code_context>
<issue_to_address>
**suggestion (bug_risk):** The SKIP note reports the leaf path, which may not be where the `_status` actually lives.

This always uses `'/'.join(path)`, even when `_status: skipped_data_missing` is set on an ancestor (e.g., adapter-level), so the note points to the metric leaf instead of the skipped node. To make the diagnostic accurate, consider having `_ancestor_skipped` return the path of the skipped node (or `None`) and using that in the note instead of the leaf path.

Suggested implementation:

```python
def _ancestor_skipped(
    results: dict[str, Any], path: tuple[str, ...],
) -> tuple[str, ...] | None:
    """
    Return the path of the nearest ancestor (including itself) that has
    `_status: skipped_data_missing`, or None if no such ancestor exists.
    """
    node: dict[str, Any] | None = results

    # Walk down the path, checking each prefix for skipped status
    # Prefixes are: (), (path[0],), (path[0], path[1]), ..., full path.
    for i in range(len(path) + 1):
        if node is None:
            break

        status = node.get("_status")
        if status == "skipped_data_missing":
            # `path[:i]` is the prefix corresponding to this node.
            return path[:i]

        if i == len(path):
            break

        key = path[i]
        child = node.get(key)
        if not isinstance(child, dict):
            break
        node = child

    return None


def _walk_leaves(

```

```python
    obs_results = observed.get("results", {})
    checks: list[BandCheck] = []
    for path, cano_val in _walk_leaves(cano_results):
        skipped_path = _ancestor_skipped(obs_results, path)
        if skipped_path is not None:
            checks.append(BandCheck(
                path=path, canonical=cano_val, observed=float("nan"),
                lower=cano_val, upper=cano_val, band_kind="skipped",
                verdict=Verdict.SKIP,
                note=(
                    "observed sub-result skipped (data missing) at "
                    f"{'/'.join(skipped_path) or '<root>'}"
                ),
            ))
            continue

```
</issue_to_address>

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

Comment thread benchmarks/tolerance.py
Comment on lines +191 to +197
if _ancestor_skipped(obs_results, path):
checks.append(BandCheck(
path=path, canonical=cano_val, observed=float("nan"),
lower=cano_val, upper=cano_val, band_kind="skipped",
verdict=Verdict.SKIP,
note=(
f"observed sub-result skipped (data missing) at "

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

suggestion (bug_risk): The SKIP note reports the leaf path, which may not be where the _status actually lives.

This always uses '/'.join(path), even when _status: skipped_data_missing is set on an ancestor (e.g., adapter-level), so the note points to the metric leaf instead of the skipped node. To make the diagnostic accurate, consider having _ancestor_skipped return the path of the skipped node (or None) and using that in the note instead of the leaf path.

Suggested implementation:

def _ancestor_skipped(
    results: dict[str, Any], path: tuple[str, ...],
) -> tuple[str, ...] | None:
    """
    Return the path of the nearest ancestor (including itself) that has
    `_status: skipped_data_missing`, or None if no such ancestor exists.
    """
    node: dict[str, Any] | None = results

    # Walk down the path, checking each prefix for skipped status
    # Prefixes are: (), (path[0],), (path[0], path[1]), ..., full path.
    for i in range(len(path) + 1):
        if node is None:
            break

        status = node.get("_status")
        if status == "skipped_data_missing":
            # `path[:i]` is the prefix corresponding to this node.
            return path[:i]

        if i == len(path):
            break

        key = path[i]
        child = node.get(key)
        if not isinstance(child, dict):
            break
        node = child

    return None


def _walk_leaves(
    obs_results = observed.get("results", {})
    checks: list[BandCheck] = []
    for path, cano_val in _walk_leaves(cano_results):
        skipped_path = _ancestor_skipped(obs_results, path)
        if skipped_path is not None:
            checks.append(BandCheck(
                path=path, canonical=cano_val, observed=float("nan"),
                lower=cano_val, upper=cano_val, band_kind="skipped",
                verdict=Verdict.SKIP,
                note=(
                    "observed sub-result skipped (data missing) at "
                    f"{'/'.join(skipped_path) or '<root>'}"
                ),
            ))
            continue

@robotrocketscience

Copy link
Copy Markdown
Owner Author

[claim:review:setr:2026-05-08T08:11:58Z]

@robotrocketscience
robotrocketscience merged commit 04cdcfb into main May 8, 2026
22 of 29 checks passed
@robotrocketscience
robotrocketscience deleted the feat/issue-479-bench-3state-exit branch May 8, 2026 08:13
@robotrocketscience

Copy link
Copy Markdown
Owner Author

[release:review:setr:2026-05-08T08:13:13Z]

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

attn:review Needs review (PR open, awaiting reviewer) author-Kulili PR coordination mutex

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[v2.1] Bench dispatcher exit-code 3-state contract (ok / skipped / error)

1 participant