Skip to content

Add Phase 2 crawler rendering: manifest-driven, resumable, per-document refresh - #75

Merged
brylie merged 2 commits into
mainfrom
feat/crawler-improvements-phase-2
Aug 5, 2026
Merged

Add Phase 2 crawler rendering: manifest-driven, resumable, per-document refresh#75
brylie merged 2 commits into
mainfrom
feat/crawler-improvements-phase-2

Conversation

@brylie

@brylie brylie commented Aug 5, 2026

Copy link
Copy Markdown
Member

Description

Implements Phase 2 ("controlled backfill") of docs/specs/crawler-improvements.md, building on Phase 1's manifest and discovery (#74). Covers Requirement 3 (manifest-driven collection and resumability), Requirement 4 (content selection and document quality), Requirement 5 (per-document cache and refresh policy), and the render-side counts for Requirement 6.

Phase 1 audit

Before starting, I audited PR #74 against the spec's Phase 1 acceptance criteria: all 83 original tests, ruff, and mypy passed, and Requirements 1, 2, and the discovery-side half of Requirement 6 are correctly implemented. Two minor, non-blocking gaps were noted for a future follow-up (no discovery.cache_ttl_hours sitemap caching yet; robots/sitemap URLs aren't persisted as run metadata) — not fixed in this PR.

What's included

  • Manifest-driven, resumable rendering: crawl <site> no longer runs its own whole-site BFS deep crawl from base_url tracked by a site-wide crawl_state.json. It now renders only manifest records that discover already populated and that are due, paging through them via a new ManifestStore.list_eligible_page, and persists each result to the manifest immediately after it completes — so a stopped run resumes from where it left off rather than restarting.
  • Per-document cache/refresh policy (new crawler/policy.py, pure and independently unit-tested): decide_render() implements the spec's Requirement 5 rules — initial backfill and a trusted sitemap_lastmod change use CacheMode.WRITE_ONLY; a stale extractor version or an elapsed unchanged_audit_days window use CacheMode.ENABLED with check_cache_freshness=True. Crawl4AI's hit_validated/hit_fallback/failure cache outcomes are each handled per their specific acceptance criteria, including exponential backoff with a per-URL retry cap, and a render-time 429/503 now extends the same shared per-host HostRateLimiter suspension discovery already respects.
  • Content quality and canonical-keyed artifacts: Markdown is now keyed by canonical_url instead of raw source_url (fixing the spec-flagged bug where two URLs redirecting to the same page produced two files), with automatic rekeying via a new ManifestStore.rekey() when a render's redirect resolves to a different canonical URL. Frontmatter gains canonical_url, content_hash, language, and extractor_version. Also fixes a real gap the spec's Design Principle 7 called out: the render browser now sends the configured User-Agent instead of Crawl4AI's default Chrome-spoofing string.
  • Coverage reporting: crawl's output reports considered/rendered/saved/low-quality/failed/retried counts and a coverage ratio (current/eligible) computed from the manifest, warning when it's below the site's configured refresh.coverage_target_percent.
  • Config schema: added DiscoveryConfig.trust_lastmod, RefreshConfig (unchanged_audit_days, inactive_grace_cycles, coverage_target_percent), and CrawlerConfig.word_count_threshold; removed the now-dead max_depth/max_pages/recrawl_interval_hours (the BFS-from-base-url path and crawl_state.json are gone — GapCrawlConfig keeps its own max_depth/max_pages for the P1 gap-crawl supplement, untouched).
  • CLI: crawl drops --depth (no longer meaningful) and adds --max-urls (default 5000) and --batch-size (default 500) per the spec's CLI-flag contract; --force is re-scoped to mean "ignore each record's due-schedule."
  • Docs: updated crawler/README.md and the root README.md for the new two-step discovercrawl workflow, and mise.toml's crawl task now runs discover before crawl for each configured site.

Explicitly out of scope for this PR

Gap-crawl as a supplement for sitemap sources (P1), retrieval evaluation (P1), operator controls — pause/cancel/concurrent site jobs (P1), all P2 items, and any change to ingest/ (it already reads source_url/frontmatter independently and needs no crawler-side coupling change).

Checklist

  • I ran the relevant service test suite(s) and all tests pass
  • I ran uv run ruff check . in crawler/ and addressed any issues
  • I ran the package-local mypy and Pyrefly commands documented in CONTRIBUTING.md and addressed any issues
  • New/changed code meets the project's 80% coverage guideline (97% for crawler/tapio_crawler, uv run pytest --cov=tapio_crawler)
  • I updated README.md (both root and crawler/) since this PR changes user-facing behavior

Related issue

Continues the "Phase 2 — controlled backfill" requirements in docs/specs/crawler-improvements.md, following on from #74. Canonical tracking issue: #72.

Test plan

  • uv run pytest --cov=tapio_crawler — 108 passed, 97% coverage
  • uv run ruff check . — clean
  • uv run mypy tapio_crawler and uv run pyrefly check tapio_crawler — clean
  • Manual smoke run against the real network (uv run tapio-crawler discover migri && uv run tapio-crawler crawl migri --max-urls 5) if a reviewer wants end-to-end confirmation before merging

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Added a two-step workflow: discover URLs first, then render eligible records.
    • Added resumable rendering with batching, URL limits, retries, caching, and forced refreshes.
    • Added detailed crawl summaries covering progress, failures, HTTP responses, cache status, and coverage.
    • Added canonical URL handling, redirects, robots and scope checks, quality fallbacks, and persistent document metadata.
  • Bug Fixes

    • Improved handling of rate limits, temporary failures, retries, and failed site discovery.
  • Documentation

    • Updated crawler usage, configuration, discovery, refresh scheduling, and failure-reporting guidance.

…nt refresh

Replaces crawl's whole-site BFS deep crawl and crawl_state.json with
manifest-driven rendering: only due records are rendered, progress is
persisted after each URL for resumability, and per-document cache mode
follows the spec's refresh policy (initial backfill, trusted-lastmod
changes, stale extractor version, and scheduled audits). Artifacts are
now keyed by canonical_url with redirect-driven rekeying, and the render
browser sends the configured User-Agent instead of spoofing Chrome.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Aug 5, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@brylie, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 38 minutes

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 2396835b-739a-4e1f-a4fd-5d51df29381a

📥 Commits

Reviewing files that changed from the base of the PR and between 01be62d and db9f0c4.

📒 Files selected for processing (7)
  • crawler/tapio_crawler/config/config_models.py
  • crawler/tapio_crawler/crawler/policy.py
  • crawler/tapio_crawler/manifest/store.py
  • crawler/tests/crawler/test_crawler.py
  • crawler/tests/crawler/test_policy.py
  • crawler/tests/manifest/test_store.py
  • crawler/tests/test_cli.py
📝 Walkthrough

Walkthrough

The crawler now separates URL discovery from manifest-driven rendering. It selects due records, applies refresh and retry policies, renders URLs with bounded concurrency, persists documents and failures, and reports coverage and run status.

Changes

Manifest contracts, storage, and scheduling

Layer / File(s) Summary
Configuration, manifest storage, and render policy
crawler/tapio_crawler/config/config_models.py, crawler/tapio_crawler/manifest/*, crawler/tapio_crawler/crawler/policy.py, crawler/tapio_crawler/discovery/rate_limiter.py, crawler/tests/config/*, crawler/tests/manifest/*, crawler/tests/crawler/test_policy.py
Refresh settings, retry tracking, eligible-record pagination, verbatim saves, canonical rekeying, filtered counts, and per-record render decisions are now defined and tested.

Manifest record rendering

Layer / File(s) Summary
Manifest record rendering
crawler/tapio_crawler/crawler/crawler.py, crawler/tests/crawler/test_crawler.py
The crawler renders due manifest records, handles caching, retries, redirects, scope, robots, rate limits, failures, coverage, canonical metadata, and Markdown output.

Runner, CLI, and site workflow

Layer / File(s) Summary
Runner, CLI, and site workflow
crawler/tapio_crawler/crawler/runner.py, crawler/tapio_crawler/cli.py, mise.toml, README.md, crawler/README.md, crawler/tests/crawler/test_runner.py, crawler/tests/test_cli.py
Runner and CLI APIs now use render limits and RenderRunSummary. The crawl task discovers each site before rendering and reports failures, coverage, and run counts.

Estimated code review effort: 4 (Complex) | ~60 minutes

Sequence Diagram(s)

sequenceDiagram
  participant MiseTask
  participant ManifestStore
  participant CrawlerRunner
  participant Crawl4AICrawler
  MiseTask->>ManifestStore: discover site URLs
  MiseTask->>CrawlerRunner: start crawl with limits
  CrawlerRunner->>Crawl4AICrawler: render due manifest records
  Crawl4AICrawler->>ManifestStore: persist records and documents
  CrawlerRunner-->>MiseTask: return RenderRunSummary
Loading

Possibly related PRs

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 55.38% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main change: Phase 2 manifest-driven, resumable crawler rendering with per-document refresh.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/crawler-improvements-phase-2

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.

@github-actions

github-actions Bot commented Aug 5, 2026

Copy link
Copy Markdown

CI Results

Service Check Result
crawler ruff (lint) ✅ Passed
crawler mypy ✅ Passed
crawler pyrefly ✅ Passed
crawler pytest + coverage ✅ Passed
ingest ruff (lint) ✅ Passed
ingest mypy ✅ Passed
ingest pyrefly ✅ Passed
ingest pytest + coverage ✅ Passed
backend ruff (lint) ✅ Passed
backend mypy ✅ Passed
backend pyrefly ✅ Passed
backend pytest + coverage ✅ Passed
app lint (eslint + prettier) ✅ Passed

All checks passed. 🎉

@coderabbitai coderabbitai 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.

Actionable comments posted: 3

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
crawler/tapio_crawler/discovery/rate_limiter.py (1)

111-122: 🎯 Functional Correctness | 🔴 Critical | ⚡ Quick win

Fix the unparenthesized except clause in parse_retry_after.

crawler/tapio_crawler/discovery/rate_limiter.py has the same except TypeError, ValueError, OverflowError: pattern, so the module cannot compile with Python 3. Use a tuple for the exception types before the colon.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crawler/tapio_crawler/discovery/rate_limiter.py` around lines 111 - 122,
Update the exception handler in parse_retry_after to use a parenthesized tuple
for TypeError, ValueError, and OverflowError, preserving the existing fallback
behavior while making the module valid Python 3 syntax.
🧹 Nitpick comments (3)
crawler/tests/crawler/test_policy.py (1)

18-28: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add a Google-style docstring to _record.

Document the helper summary, overrides, and returned ManifestRecord. This helper builds the policy test fixture and is part of the test module’s local API.

As per coding guidelines, “Use Google-style docstrings for all Python functions and classes, documenting summaries, parameters, return types, exceptions, examples, notes, TODOs, deprecations, references, and warnings where applicable.”

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crawler/tests/crawler/test_policy.py` around lines 18 - 28, Add a
Google-style docstring to the `_record` test helper describing that it builds a
policy-test `ManifestRecord` fixture, documenting the `overrides` keyword
arguments and the returned `ManifestRecord`.

Source: Coding guidelines

crawler/tapio_crawler/config/config_models.py (1)

65-70: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Document the RefreshConfig fields in the class docstring.

Add a Google-style Attributes section for unchanged_audit_days, inactive_grace_cycles, and coverage_target_percent. This makes the public configuration contract clear.

As per coding guidelines, “Use Google-style docstrings for all Python functions and classes, documenting summaries, parameters, return types, exceptions, examples, notes, TODOs, deprecations, references, and warnings where applicable.”

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crawler/tapio_crawler/config/config_models.py` around lines 65 - 70, Expand
the RefreshConfig class docstring with a Google-style Attributes section
documenting unchanged_audit_days, inactive_grace_cycles, and
coverage_target_percent, including each field’s purpose and expected value
semantics. Keep the existing class summary and field definitions unchanged.

Source: Coding guidelines

crawler/tests/test_cli.py (1)

49-53: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Tighten the status assertion.

"complete" in result.stdout also matches the string incomplete. The assertion passes for either run state. Assert the rendered phrase instead.

♻️ Proposed change
     assert result.exit_code == 0
-    assert "complete" in result.stdout
+    assert "for example: complete." in result.stdout
     assert "saved 1" in result.stdout
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crawler/tests/test_cli.py` around lines 49 - 53, Update the status assertion
in the CLI test to match the complete rendered phrase rather than checking for
the substring "complete", while preserving the existing expectations for saved
output, warnings, and manifest store closure.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@crawler/tapio_crawler/crawler/crawler.py`:
- Around line 384-399: Update _next_retry in
crawler/tapio_crawler/crawler/crawler.py:384-399 to park records after
MAX_RETRY_COUNT instead of returning retry_after=None; use a persistent long
retry_after or a terminal state excluded by list_eligible_page/decide_render.
Update crawler/tests/crawler/test_crawler.py:232-243 to perform a second render
pass and assert the parked record is not rendered again.

In `@crawler/tapio_crawler/manifest/store.py`:
- Around line 232-236: Update the redirect/rekey flow around _write and the
manifest DELETE to first load any existing record for new_record.canonical_url,
merge its accumulated fields (including discovery_source and earliest
first_seen_at) with the old record, then delete the old identity and write the
merged record within one transaction. Preserve the target record’s persisted
state when both identities exist, and add a test covering that case.
- Around line 43-47: Update manifest database initialization to inspect PRAGMA
table_info(manifest) and add the missing retry_count column with ALTER TABLE ...
ADD COLUMN retry_count INTEGER NOT NULL DEFAULT 0 before writes occur. Preserve
existing schemas and initialization behavior, and add a regression test that
opens a pre-change manifest schema and successfully saves a record.

---

Outside diff comments:
In `@crawler/tapio_crawler/discovery/rate_limiter.py`:
- Around line 111-122: Update the exception handler in parse_retry_after to use
a parenthesized tuple for TypeError, ValueError, and OverflowError, preserving
the existing fallback behavior while making the module valid Python 3 syntax.

---

Nitpick comments:
In `@crawler/tapio_crawler/config/config_models.py`:
- Around line 65-70: Expand the RefreshConfig class docstring with a
Google-style Attributes section documenting unchanged_audit_days,
inactive_grace_cycles, and coverage_target_percent, including each field’s
purpose and expected value semantics. Keep the existing class summary and field
definitions unchanged.

In `@crawler/tests/crawler/test_policy.py`:
- Around line 18-28: Add a Google-style docstring to the `_record` test helper
describing that it builds a policy-test `ManifestRecord` fixture, documenting
the `overrides` keyword arguments and the returned `ManifestRecord`.

In `@crawler/tests/test_cli.py`:
- Around line 49-53: Update the status assertion in the CLI test to match the
complete rendered phrase rather than checking for the substring "complete",
while preserving the existing expectations for saved output, warnings, and
manifest store closure.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 369407ef-4cc8-43fd-888e-0da7bd13ab80

📥 Commits

Reviewing files that changed from the base of the PR and between 760ae5e and 01be62d.

📒 Files selected for processing (20)
  • README.md
  • crawler/README.md
  • crawler/tapio_crawler/cli.py
  • crawler/tapio_crawler/config/config_models.py
  • crawler/tapio_crawler/config/site_configs.yaml
  • crawler/tapio_crawler/crawler/crawler.py
  • crawler/tapio_crawler/crawler/policy.py
  • crawler/tapio_crawler/crawler/runner.py
  • crawler/tapio_crawler/discovery/rate_limiter.py
  • crawler/tapio_crawler/manifest/models.py
  • crawler/tapio_crawler/manifest/store.py
  • crawler/tests/config/test_config_manager.py
  • crawler/tests/config/test_config_models.py
  • crawler/tests/conftest.py
  • crawler/tests/crawler/test_crawler.py
  • crawler/tests/crawler/test_policy.py
  • crawler/tests/crawler/test_runner.py
  • crawler/tests/manifest/test_store.py
  • crawler/tests/test_cli.py
  • mise.toml
💤 Files with no reviewable changes (2)
  • crawler/tests/conftest.py
  • crawler/tapio_crawler/config/site_configs.yaml

Comment on lines +384 to +399
@staticmethod
def _next_retry(
record: ManifestRecord,
now: datetime,
summary: RenderRunSummary,
) -> tuple[int, datetime | None]:
"""Return the next retry count/timestamp for a failed or unconfirmed attempt.

Counts it in ``summary.retried`` only when a future retry is actually
scheduled (the cap isn't exceeded).
"""
retry_count = record.retry_count + 1
if retry_count > MAX_RETRY_COUNT:
return retry_count, None
summary.retried += 1
return retry_count, now + timedelta(seconds=retry_backoff_seconds(retry_count))

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟠 Major | 🏗️ Heavy lift

The retry cap clears the backoff instead of parking the URL, and the test encodes that behavior. _next_retry returns retry_after=None once retry_count exceeds MAX_RETRY_COUNT. decide_render skips a record only while retry_after > now, so the record becomes due on the very next run with no backoff at all, and retry_count grows without bound.

  • crawler/tapio_crawler/crawler/crawler.py#L384-L399: past the cap, park the record. Either keep a long retry_after in place or move the record to a terminal state that list_eligible_page or decide_render excludes.
  • crawler/tests/crawler/test_crawler.py#L232-L243: run a second render pass in the test and assert the parked record is not rendered again, instead of asserting only retry_count and retry_after is None.
📍 Affects 2 files
  • crawler/tapio_crawler/crawler/crawler.py#L384-L399 (this comment)
  • crawler/tests/crawler/test_crawler.py#L232-L243
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crawler/tapio_crawler/crawler/crawler.py` around lines 384 - 399, Update
_next_retry in crawler/tapio_crawler/crawler/crawler.py:384-399 to park records
after MAX_RETRY_COUNT instead of returning retry_after=None; use a persistent
long retry_after or a terminal state excluded by
list_eligible_page/decide_render. Update
crawler/tests/crawler/test_crawler.py:232-243 to perform a second render pass
and assert the parked record is not rendered again.

Comment thread crawler/tapio_crawler/manifest/store.py
Comment thread crawler/tapio_crawler/manifest/store.py Outdated
…ema migration

- decide_render now parks a record once its retry cap is exhausted without
  ever rendering successfully, instead of falling through to
  "initial_backfill" and being retried forever.
- ManifestStore.rekey merges discovery provenance with an existing record at
  the target canonical URL instead of silently overwriting it.
- ManifestStore adds any manifest column missing from an older database file
  on open, so a pre-Phase-2 manifest.db doesn't fail on the first
  retry_count read/write.
- Fixed a CLI test whose "complete" substring check also matched
  "incomplete".

The rate_limiter.py `except TypeError, ValueError, OverflowError:` review
comment was a false alarm: confirmed valid PEP 758 syntax under the pinned
Python 3.14.2, left unchanged.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@brylie

brylie commented Aug 5, 2026

Copy link
Copy Markdown
Member Author

@coderabbitai this project uses Python 3.14, so this is valid syntax:

Fix the unparenthesized except clause in parse_retry_after.

crawler/tapio_crawler/discovery/rate_limiter.py has the same except TypeError, ValueError, OverflowError: pattern, so the module cannot compile with Python 3. Use a tuple for the exception types before the colon.

@coderabbitai

coderabbitai Bot commented Aug 5, 2026

Copy link
Copy Markdown

Tip

For best results, initiate chat on the files or code changes.

@brylie You are correct. Python 3.14 supports unparenthesized multiple exception types in an except clause. The code in parse_retry_after is valid for this project. I withdraw the finding.

🐇


✏️ Learnings added
Learnt from: brylie
URL: https://github.com/Finntegrate/tapio/pull/75

Timestamp: 2026-08-05T16:53:40.268Z
Learning: This project uses Python 3.14. In Python files such as `crawler/tapio_crawler/discovery/rate_limiter.py`, unparenthesized multiple exception types in an `except` clause, for example `except TypeError, ValueError, OverflowError:`, are valid syntax.

Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.

You are interacting with an AI system.

@sonarqubecloud

sonarqubecloud Bot commented Aug 5, 2026

Copy link
Copy Markdown

@brylie
brylie merged commit 9711867 into main Aug 5, 2026
7 checks passed
@brylie
brylie deleted the feat/crawler-improvements-phase-2 branch August 5, 2026 17:04
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.

1 participant