Skip to content

Add AI docs support for publishing and resolving documentation artifacts - #41

Closed
oguzkocer wants to merge 7 commits into
trunkfrom
add/ai-docs-support
Closed

Add AI docs support for publishing and resolving documentation artifacts#41
oguzkocer wants to merge 7 commits into
trunkfrom
add/ai-docs-support

Conversation

@oguzkocer

@oguzkocer oguzkocer commented Jun 21, 2026

Copy link
Copy Markdown
Contributor

What & why

Adds first-class support for shipping a compact, machine-readable AI docs bundle alongside a library's normal Maven artifacts, plus a consumer-side plugin to fetch and unpack it on demand.

The goal: let tooling (e.g. an AI coding assistant working in a downstream repo) read a small, structured API reference for a dependency instead of crawling large generated sources. The bundle is attached as a standard Maven classifier artifact (ai-docs, a .zip), so it rides the existing publish/resolve infrastructure — no custom upload/download paths.

How it works

Publisher side — com.automattic.android.publish-to-s3

The existing plugin gains an aiDocs { } extension. Pointing it at a directory zips that directory and attaches the zip to every Maven publication as the ai-docs classifier.

plugins {
    id "com.automattic.android.publish-to-s3"
}

aiDocs {
    // A static directory, or (preferably) a docs-generation task's output.
    from(file("docs/ai-reference"))
    // from(generateDocs.flatMap { it.outputDirectory })
}
  • Registers a zipAiDocs task (a Gradle Zip, so the archive is reproducible — stable entry order, fixed timestamps, forward-slash entries — and builtBy is wired automatically).
  • The archive is written under build/ai-docs-archive/ and attached to each non–plugin-marker MavenPublication as classifier = "ai-docs", extension = "zip".
  • When from(...) is a task output, zipAiDocs depends on it automatically; the source need not exist at configuration time.

Consumer side — com.automattic.android.ai-docs (new plugin)

A separate, consumer-only plugin (does not apply maven-publish).

plugins {
    id "com.automattic.android.ai-docs"
}

aiDocs {
    // Version optional — inferred from the dependency graph when omitted.
    resolve("rs.wordpress.api:kotlin:1.2.3")
}
  • Registers resolveAiDocs, which resolves group:artifact:version:ai-docs@zip and unpacks it to build/ai-docs/<group>/<artifact>/<version>/.
  • Output lives under build/, so it is cleaned by clean and implicitly git-ignored.
  • Resolution is lenient: a coordinate that publishes no ai-docs artifact is skipped with a per-module warning instead of failing the task.
  • Stale versions of a module are removed on resolve, so only the current version remains on disk.

Key design decisions

  • Classifier artifact, not a custom S3 path — standard Maven, resolves through the consumer's existing repositories.
  • Resolution uses the consumer's repositories (settings-level / dependencyResolutionManagement). The plugin deliberately does not register its own repository, which would break builds using RepositoriesMode.FAIL_ON_PROJECT_REPOS.
  • Configuration-cache compatible: the task carries only a Map<String, File> (coordinate → file) and tracks resolved files via @InputFiles, so a re-published SNAPSHOT with new content re-extracts.
  • Requires Gradle 7.4+ on the consuming build (uses ArtifactCollection.getResolvedArtifacts()).

Files

  • AiDocsExtension.ktfrom(...) / resolve(...) DSL (shared by both plugins).
  • AiDocsPlugin.kt — consumer plugin + the publisher/consumer wiring.
  • ZipAiDocsTask.kt was removed in favor of Gradle's built-in Zip task.
  • ResolveAiDocsTask.kt — resolve + unpack + stale cleanup + zip-slip guard.
  • PublishToS3Plugin.kt — registers the shared aiDocs extension and the publishing wiring.
  • AiDocsFunctionalTest.kt — functional coverage.

Testing

Automated

./gradlew :plugin:check

Functional tests cover:

  • zipAiDocs produces a non-empty archive at build/ai-docs-archive/ai-docs.zip.
  • resolveAiDocs downloads, unpacks, and removes stale versions (publishes a test module with an ai-docs classifier to a file Maven repo).
  • A dependency without an ai-docs artifact resolves cleanly with no output (graceful skip).
  • resolveAiDocs runs with --configuration-cache (guards against carrying non-serializable values into the task).
  • Applying both publish-to-s3 and ai-docs together shares one aiDocs extension (no name clash).

Manual end-to-end

This plugin is published tag-based, so manual testing uses a local Maven repo. Example using a kotlin library as producer and any project as consumer.

  1. Publish the plugin locally (the local Maven repo in plugin/build.gradle.kts, or :plugin:publishToMavenLocal):

    ./gradlew :plugin:publishAllPublicationsToLocalRepository
    

    Point the test projects' pluginManagement at that repo and use the published version.

  2. Publish a producer with docs. In a library module:

    aiDocs { from(file("docs/ai-reference")) }

    then publish (:lib:publishToMavenLocal, or prepareToPublishToS3 ... publish). Verify the published coordinate has a *-ai-docs.zip next to the .jar/.pom/.module, e.g.:

    ls ~/.m2/repository/<group>/<artifact>/<version>/*-ai-docs.zip
    unzip -l ~/.m2/repository/<group>/<artifact>/<version>/*-ai-docs.zip   # expect the docs files
    
  3. Resolve in a consumer. In another project, ensure the repo hosting the artifact is configured (e.g. mavenLocal() in dependencyResolutionManagement), then:

    plugins { id "com.automattic.android.ai-docs" }
    aiDocs { resolve("<group>:<artifact>:<version>") }
    ./gradlew resolveAiDocs
    ls build/ai-docs/<group>/<artifact>/<version>/    # expect the unpacked docs
    
  4. Re-run is incremental / cleanable.

    ./gradlew resolveAiDocs            # UP-TO-DATE on the second run
    ./gradlew resolveAiDocs --configuration-cache   # stores, then reuses
    ./gradlew clean                    # removes build/ai-docs
    
  5. Stale cleanup. Bump the resolved version, re-run resolveAiDocs, and confirm the previous version directory under build/ai-docs/<group>/<artifact>/ is gone.

  6. Graceful skip. Point resolve(...) at a dependency that has no ai-docs classifier and confirm resolveAiDocs succeeds with an AI docs artifact not found for ... warning and no output.

Notes for reviewers

  • from(...) accepts a File, Directory, or Provider<Directory>; prefer a task-output provider so zipAiDocs orders after doc generation.
  • If the version is omitted in resolve(...), it is inferred from declared dependencies; the build fails fast if it can't be determined unambiguously (e.g. version supplied only via a platform/BOM).

Introduces an `aiDocs` concept that allows libraries to publish structured
documentation alongside Maven artifacts, and consumers to resolve them
on-demand.

Publisher side (via existing `publish-to-s3` plugin):
- `aiDocs { from(file("docs/ai-reference")) }` zips and attaches as
  `ai-docs` classifier artifact to all Maven publications

Consumer side (new `com.automattic.android.ai-docs` plugin):
- `aiDocs { resolve("group:artifact") }` declares docs to fetch
- `./gradlew resolveAiDocs` resolves, unpacks to `.ai-docs/`, and
  cleans stale versions — resolution only happens at task execution
  time, never during configuration
@oguzkocer oguzkocer added the enhancement New feature or request label Jun 21, 2026
Write resolved/unpacked AI docs to `build/ai-docs` instead of the
project-root `.ai-docs`. They are a build artifact, so this makes them
cleaned by `clean` and implicitly gitignored, with no per-consumer
`.gitignore` entry required.

Copilot AI 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.

Pull request overview

Adds an aiDocs capability to the Gradle plugin ecosystem in this repo, enabling producers to publish a structured docs ZIP alongside Maven artifacts and consumers to resolve/unpack those docs on demand.

Changes:

  • Introduces ZipAiDocsTask and wires it into com.automattic.android.publish-to-s3 to attach an ai-docs classifier ZIP to Maven publications.
  • Adds a new consumer plugin com.automattic.android.ai-docs with ResolveAiDocsTask to fetch and unpack AI docs artifacts.
  • Adds functional tests and registers the new plugin in plugin/build.gradle.kts.

Reviewed changes

Copilot reviewed 7 out of 7 changed files in this pull request and generated 7 comments.

Show a summary per file
File Description
plugin/src/main/kotlin/com/automattic/android/publish/ZipAiDocsTask.kt New task to zip a configured AI docs directory for publishing.
plugin/src/main/kotlin/com/automattic/android/publish/ResolveAiDocsTask.kt New task to resolve and unpack ai-docs ZIP artifacts to a local directory.
plugin/src/main/kotlin/com/automattic/android/publish/PublishToS3Plugin.kt Creates aiDocs extension and wires publishing configuration into the existing publish plugin.
plugin/src/main/kotlin/com/automattic/android/publish/AiDocsPlugin.kt New consumer plugin + shared publishing/resolving configuration helpers.
plugin/src/main/kotlin/com/automattic/android/publish/AiDocsExtension.kt New extension API (from(...), resolve(...)) for producer/consumer configuration.
plugin/src/functionalTest/kotlin/com/automattic/android/publish/AiDocsFunctionalTest.kt Functional tests for zipping and consumer plugin application.
plugin/build.gradle.kts Registers the new com.automattic.android.ai-docs Gradle plugin.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +30 to +32
val entryPath = file.relativeTo(sourceDir).path
zos.putNextEntry(ZipEntry(entryPath))
file.inputStream().use { it.copyTo(zos) }
Comment on lines +68 to +75
zip.entries().asSequence().forEach { entry ->
val targetFile = File(targetDir, entry.name)
if (entry.isDirectory) {
targetFile.mkdirs()
} else {
unpackEntry(zip, entry, targetFile)
}
}
Comment on lines +35 to +39
val parts = notation.split(":")
val group = parts[0]
val artifact = parts[1]
val version = parts[2]

Comment thread plugin/src/main/kotlin/com/automattic/android/publish/ResolveAiDocsTask.kt Outdated
Comment on lines +16 to +19
override fun apply(project: Project) {
val extension = project.extensions.create("aiDocs", AiDocsExtension::class.java)
project.configureAiDocsResolving(extension)
}
Comment on lines +72 to +74
// Resolved docs are a build artifact: cleaned by `clean` and implicitly gitignored.
task.outputDirectory.set(rootProject.layout.buildDirectory.dir("ai-docs"))
}
Comment on lines +65 to +66
assertTrue(result.output.contains("resolveAiDocs") || !result.output.contains("resolveAiDocs"),
"Plugin should apply without error even with no dependencies configured")
- `zipAiDocs`: write ZIP entries with forward slashes (`invariantSeparatorsPath`)
  so the archive layout is correct on Windows
- `resolveAiDocs`: guard unpacking against zip-slip path traversal; reject
  entries that resolve outside the target directory
- `resolveAiDocs`: match artifact `version` (not just group/name) so the right
  docs are unpacked when multiple versions are present
- `resolveAiDocs`: validate the coordinate notation with a clear error instead
  of an opaque `IndexOutOfBoundsException`
- Reuse an existing `aiDocs` extension when present so `com.automattic.android.publish-to-s3`
  and `com.automattic.android.ai-docs` can be applied together without a name clash
- Replace the tautological functional-test assertion with real checks and add
  coverage for the resolve notation and the both-plugins-applied case

Copilot AI 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.

Pull request overview

Copilot reviewed 7 out of 7 changed files in this pull request and generated 8 comments.

Comment on lines +27 to +31
sourceDir.walkTopDown()
.filter { it.isFile }
.forEach { file ->
// Use forward slashes so the ZIP layout is correct on all platforms.
val entryPath = file.relativeTo(sourceDir).invariantSeparatorsPath
Comment on lines +61 to +69
val parts = notation.split(":")
val group = parts[0]
val artifact = parts[1]
val version = if (parts.size >= NOTATION_WITH_VERSION_PARTS) {
parts[2]
} else {
resolveVersionFromDependencyGraph(group, artifact)
}

Comment thread plugin/src/main/kotlin/com/automattic/android/publish/AiDocsPlugin.kt Outdated
Comment on lines +78 to +80
// Resolved docs are a build artifact: cleaned by `clean` and implicitly gitignored.
task.outputDirectory.set(rootProject.layout.buildDirectory.dir("ai-docs"))
}
Comment on lines +13 to +15
val projectDir = File("build/functionalTest-aiDocs")
projectDir.mkdirs()

Comment on lines +48 to +50
val projectDir = File("build/functionalTest-aiDocs-consumer")
projectDir.mkdirs()

Comment on lines +75 to +77
val projectDir = File("build/functionalTest-aiDocs-both")
projectDir.mkdirs()

Comment on lines +39 to +43
if (!publication.name.endsWith("PluginMarkerMaven")) {
publication.artifact(zipTask.flatMap { it.outputZip }) {
it.classifier = AI_DOCS_CLASSIFIER
it.extension = "zip"
}
- `zipAiDocs`: sort files by relative path before zipping for a deterministic
  ZIP (reproducible output / stable build cache)
- `resolveAiDocs`: validate the `resolve(...)` notation at configuration time
  with a clear error instead of an `IndexOutOfBoundsException`
- `resolveAiDocs`: use `maybeCreate` for the `aiDocs` configuration so it does
  not fail if the configuration already exists
- `resolveAiDocs`: write to the current project's build dir instead of
  `rootProject`, avoiding races when applied to multiple subprojects
- Publishing: mark the `ai-docs` artifact `builtBy(zipAiDocs)` so publish never
  races ahead of the ZIP being produced
- Functional tests: delete the fixed project dirs before recreating them so the
  tests are hermetic

Copilot AI 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.

Pull request overview

Copilot reviewed 7 out of 7 changed files in this pull request and generated 5 comments.

Comment on lines +32 to +36
// Use forward slashes so the ZIP layout is correct on all platforms.
val entryPath = file.relativeTo(sourceDir).invariantSeparatorsPath
zos.putNextEntry(ZipEntry(entryPath))
file.inputStream().use { it.copyTo(zos) }
zos.closeEntry()
Comment on lines +6 to +14
import org.gradle.api.tasks.InputDirectory
import org.gradle.api.tasks.OutputFile
import org.gradle.api.tasks.TaskAction
import java.util.zip.ZipEntry
import java.util.zip.ZipOutputStream

abstract class ZipAiDocsTask : DefaultTask() {
@get:InputDirectory
abstract val sourceDirectory: DirectoryProperty
Comment on lines +54 to +60
val deps = extension.dependencies.getOrElse(emptyList())
if (deps.isEmpty()) return@afterEvaluate

val aiDocsConfig = configurations.maybeCreate("aiDocs").apply {
isTransitive = false
isCanBeConsumed = false
}
Comment on lines +57 to +69
if (isUpToDate(versionDir)) {
logger.lifecycle("AI docs up-to-date: ${versionDir.absolutePath}")
return@forEach
}

versionDir.mkdirs()
unpackZip(matchingArtifact.file, versionDir)
logger.lifecycle("AI docs saved to: ${versionDir.absolutePath}")
}
}

private fun isUpToDate(versionDir: File): Boolean =
versionDir.exists() && versionDir.list()?.isNotEmpty() == true
Comment on lines +29 to +35
@TaskAction
fun resolve() {
val outputDir = outputDirectory.get().asFile
outputDir.mkdirs()

val resolvedArtifacts = aiDocsConfiguration.resolvedConfiguration.resolvedArtifacts

Address the recurring Gradle-idiom review findings at their root rather than
patching each symptom.

- Replace the hand-rolled `ZipAiDocsTask` with Gradle's `Zip` task: reproducible
  archive (stable order + fixed timestamps), forward-slash entries, correct
  input path-sensitivity, and automatic `builtBy` wiring. The custom task is
  deleted.
- `resolveAiDocs`: extract into a freshly cleaned version directory so an
  interrupted run can't leave a partial/mixed docs tree. Drops the fragile
  "non-empty dir means up-to-date" check; Gradle's own up-to-date check still
  skips the task when inputs/outputs are unchanged.
- Add a functional test that publishes an `ai-docs` artifact to a file Maven repo
  and asserts `resolveAiDocs` unpacks it and removes stale versions.
- Document that resolution uses the consumer's repositories on purpose; a
  plugin-added repository would break `FAIL_ON_PROJECT_REPOS` builds.
- Resolve the `aiDocs` configuration through a lenient artifact view so a
  coordinate that publishes no `ai-docs` classifier is skipped with a per-module
  warning instead of failing `resolveAiDocs`
- Track resolved artifact files as `@InputFiles` and carry only a
  coordinate->file map into the task, so re-published content re-extracts and the
  task is compatible with the configuration cache
- Unpack into a freshly cleaned version directory and remove other versions of
  the same module (a configuration resolves one version per module)
- Write the producer ZIP to `build/ai-docs-archive` so it doesn't overlap the
  consumer's `build/ai-docs` output directory
- Set the task description in registration; note the Gradle 7.4+ requirement
- Add functional tests for resolve/unpack/cleanup, a missing `ai-docs` artifact,
  and configuration-cache compatibility

Copilot AI 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.

Pull request overview

Copilot reviewed 6 out of 6 changed files in this pull request and generated 3 comments.

Comment on lines +131 to +142
private fun Project.resolveVersionFromDependencyGraph(group: String, artifact: String): String {
val matchingDep = configurations
.flatMap { it.dependencies }
.find { it.group == group && it.name == artifact }

return matchingDep?.version
?: throw IllegalStateException(
"Cannot resolve version for '$group:$artifact'. " +
"Either add it as a dependency or specify the version explicitly: " +
"resolve(\"$group:$artifact:VERSION\")"
)
}
Comment on lines +46 to +48
val parts = coordinate.split(":")
if (parts.size != COORDINATE_PARTS) return@forEach
val (group, artifact, version) = parts
- `resolveVersionFromDependencyGraph` collects all declared versions for a module
  and fails fast when none (e.g. the version comes from a platform/BOM or a
  constraint) or multiple distinct versions are found, instead of silently
  picking the first declared dependency
- `resolveAiDocs` fails fast on a malformed coordinate instead of silently
  skipping it, surfacing wiring bugs rather than leaving stale docs
- Mark the `aiDocs` configuration `isCanBeResolved = true` so its resolve-only
  role is explicit under stricter Gradle configuration-role checks
@oguzkocer
oguzkocer marked this pull request as ready for review June 25, 2026 17:00
@oguzkocer
oguzkocer requested review from ParaskP7 and wzieba June 25, 2026 17:01
@oguzkocer

oguzkocer commented Jun 25, 2026

Copy link
Copy Markdown
Contributor Author

@wzieba @ParaskP7 I wanted to note that: because I've been away from the Gradle world for quite some time, I had to heavily rely on Claude for best practices for this PR. This current implementation looks reasonable to me from what I know and remember about implementing custom Gradle plugins/tasks, but I can't be sure, so I'm hoping that you can help me address any issues during the PR review.

I was also thinking that even if there are some minor issues, it shouldn't impact any of the existing clients, and I'll find out about them during integration. End-to-end tests I have tried worked out well, but the integration I have is still a POC, so I again can't be sure that it's 100% what we want.

Please let me know if you have any questions/concerns!

Edit: I forgot to note that all of Copilot's comments should be addressed. I didn't reply to each comment because after its second review, I realized the direction of the PR was wrong and made a big refactor. The three comments from the latest review, which came after the refactor, have all been addressed in 0916199.

@wzieba

wzieba commented Jun 26, 2026

Copy link
Copy Markdown
Member

Hi @oguzkocer ! Thanks for working on this, it sounds interesting!

The goal: let tooling (e.g. an AI coding assistant working in a downstream repo) read a small, structured API reference for a dependency instead of crawling large generated sources.

I've seen some similar efforts in other projects. E.g. meta-wearables-dat-* offers plugins and skills (AI-Assisted Development section) which, I'm guessing, aim for the same: help LLMs get context to work more efficient.

On my AI Enablement program I've experimented with this idea in parsely-android and I've seen solid improvement when asked to integrate the library with the plugin/skill, vs. "just read source" approach.


One quick thought I have is:

  • I like that "AI docs as part of Maven package" is bundled right away: I don't have to think about installing a plugin/skill so it's good for discovery
  • I'm wonder if LLMs will detect these AI docs easilly - I don't know if build dir is something that LLMs check often. But I'm guessing you tested it.
  • It'd be great if we also had a similar solution for iOS/SPM. What I like about plugin/skill approach is that they're platform-agnostic. But anyway, I guess "no iOS" shouldn't be a strong argument against the solution in this PR.

These are my first thoughts @oguzkocer but I have a question for you: how critical is this for you?

Together with @ParaskP7 we're doing RSM 2.0 right now and I try to prioritize my project whenever I can. This PR rather falls into category of non-essentials, so I think I can't work much on it right now.

At the same time I don't want to block you with testing it in real life. Maybe we could release this as an -alpha version or something? We don't have to actively work on this Gradle Plugin so I think it won't be a problem.

After the RSM 2.0 ends, I could take a deeper look and make a post-mortem review, which we could address in another PR, if needed.

WDYT?

@ParaskP7

ParaskP7 commented Jun 26, 2026

Copy link
Copy Markdown
Contributor

This is indeed an interesting idea @oguzkocer and what @wzieba said above, +1 for me too! 💯

Btw, I did skim this change real quick, just to understand the basics of it, mainly the e2e flow, so that when I'll find time to properly review it I would be prepared. And, as I was doing so, I want to quickly note something that confused me, and maybe it will others too. It is about this aiDocs { ... } signature, which might be (somehow) misleading, and, we might want to think of a different name. To my understanding, yes, these (library) generated docs meant for an AI to read them (client side), but not that the docs are written by an AI, is it not? I read it as, the library author that is using this plugin may or may not use AI to generate these library docs, but nothing here requires or implies it, isn't it? And thus, that all, makes me think if we need all the AI reference with this change, or if we should make it generic and let the library and clients decide how they want to go about it, AI or not. Having said all that, I might be missing bits, so please take that all with a grain of salt and ignore if none of it makes sense to you, and maybe, use having it as aiDocs { ... } and docs/ai-reference etc is indeed what enables the AI flow you are envisioning. 💭 (just some random thoughts on the API of it all, thanks for being patient with me)

UPDATE: Maybe I need to run the e2e flow with a client using this new com.automattic.android.ai-docs plugin and running ./gradlew resolveAiDocs, on a declared library with aiDocs { ... } to understand it more, and thus, all the above is just my brain melting on AI atm, apologies. 😊

@oguzkocer

Copy link
Copy Markdown
Contributor Author

@wzieba @ParaskP7 Thank you both for taking a look and responding very quickly. Really appreciate it 🙇‍♂️

This PR is a part of my RSM project, so unfortunately it looks like our timelines will not match up. However, it shouldn't take too much time to port it to wordpress-rs which is the project we need it in. So, @jkmassel and I have discussed it and decided to proceed in that direction.

Just to give you some context: in wordpress-rs we have a generated wp_api.kt file that's ~200k lines at the time of this comment, and it's growing every week. It's a very cumbersome file for AI to work with not only due to its length, but also because each type occurs in many places in the file including their definition, usages in other functions/types, ffi converters etc. So, contrary to most other Kotlin libraries where this might be a nice-to-have feature, for wordpress-rs I expect it to be significantly more efficient to work with, if we can get the document generation right. And that part is the more important one for us to focus on, and not the infra side this PR handles, because that effort will determine whether it'll be worth it to us.

Once the RSM is over, I don't think I'll have the bandwidth to work on this, so I'll close this PR for now. Please feel free to use or discard the work however you wish. If you end up making it part of the plugin, we can see about using it in wordpress-rs.

To clarify one thing: when I started this project, I considered directly implementing it in wordpress-rs, but considering it's an opt-in new feature, I figured it might be better to make it available to everyone. Then again, I felt maybe our problem was not going to be a big pain point for other projects and that we might have to port it back to wordpress-rs.

Thanks again for your quick responses 🙇‍♂️

@oguzkocer oguzkocer closed this Jun 26, 2026
@oguzkocer
oguzkocer deleted the add/ai-docs-support branch June 26, 2026 17:59
@ParaskP7

Copy link
Copy Markdown
Contributor

Gotcha @oguzkocer, thank you for this update and the extra context you provided! 🙇 👍

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

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants