Open Source Protocol

Public methodology, frozen protocol version, preregistered analysis plan, and protocol-critical source code for the Illume Umbra Zener Card Test.

Transparency Statement

Many online Zener card and ESP experiments require participants to trust that the underlying implementation is correct. This project takes a different approach.

The protocol-critical source code, methodology, and statistical analysis plan are published openly so anyone can independently verify how targets are generated, how results are scored, what anonymous information is collected, and how the resulting data will be analyzed.

Transparency is considered an essential component of the experiment itself.

Protocol Information

Current Protocol Versionv2.0.0_preregistered
StatusFrozen
Frozen Date2026-06-24
Preregistration Cutoff
Confirmatory data collection begins at 2026-06-25 00:00:00 UTC. Sessions with created_at before this timestamp are considered pilot/development data and are excluded from the preregistered confirmatory dataset.
Target GenerationServer-generated after participant guess
RNG SourcePython secrets module
Primary Statistical TestTwo-sided exact binomial test
Trials per Session25
SymbolsCircle · Cross · Waves · Square · Star

Only sessions collected under v2.0.0_preregistered will be included in the preregistered confirmatory dataset.

The protocol was frozen on 2026-06-24. Confirmatory data collection begins at the following UTC day boundary, 2026-06-25 00:00:00 UTC, to provide a clear and objective separation between pilot/development data and the preregistered confirmatory dataset.

Experimental Methods

Purpose

This experiment recreates the classic five-symbol Zener card experiment using a modern, privacy-first implementation designed for independent auditing.

Participants complete 25 independent forced-choice trials. Chance expectation is 5 correct out of 25, or 20%.

Target Generation

Targets are not predetermined.

  1. The participant submits a guess.
  2. The server records the guess.
  3. The server generates exactly one target using Python's cryptographically secure secrets module.
  4. The generated target is stored server-side.
  5. The target is never returned to the browser during the experiment.

Because the target does not exist until after the participant's guess has been received, it cannot be extracted from client-side code or browser traffic.

Scoring

After all 25 trials are completed, every recorded trial is retrieved, correct guesses are counted, and final scoring occurs entirely on the server. Running scores are intentionally hidden during the experiment.

Session Integrity

Each participant receives a random anonymous session identifier. The server verifies that exactly 25 trials exist, trial numbers 1–25 are present, duplicate trials are rejected by database constraints, and final results are calculated only from complete sessions. Once recorded, trial data are not modified during score calculation.

Timing Metadata

Two anonymous timing values are stored: total session duration and a suspiciously fast completion flag. These variables may be used for future exploratory analyses.

Anonymous Data Collected

The experiment stores only:

  • Anonymous session identifier
  • Weekly session key
  • Final score
  • Trial count
  • Protocol version
  • RNG source
  • Target generation mode
  • Anonymous session timing metadata
  • Timestamp

No names, email addresses, browser fingerprints, user accounts, or cookies are required.

Preregistered Analysis Plan

Primary Hypothesis

The aggregate hit rate differs from the chance expectation of 20%.

Null hypothesis: p = 0.20

Alternative hypothesis: p ≠ 0.20

Unit of Analysis

The primary statistical analysis is conducted at the trial level using all included completed trials. Sessions are used to determine inclusion and exclusion criteria.

Because participant identity is intentionally not collected, multiple sessions from the same individual cannot be identified. This limitation is explicitly acknowledged.

Inclusion Criteria

  • Session contains exactly 25 trials.
  • Trial numbers 1–25 are present.
  • Session uses protocol version v2.0.0_preregistered.
  • Session created_at is on or after 2026-06-25 00:00:00 UTC.
  • Session has a valid server-generated score.

Exclusion Criteria

  • Session created_at is before 2026-06-25 00:00:00 UTC.
  • Incomplete trial data.
  • Different protocol version.
  • Missing timing information.
  • Flagged as suspiciously fast.

Excluded sessions may be reported separately but are excluded from confirmatory analyses.

Primary Statistical Analysis

The primary confirmatory analysis will use a two-sided exact binomial test.

  • Total sessions
  • Total trials
  • Total hits
  • Observed hit rate
  • Expected hit rate
  • 95% confidence interval
  • Two-sided p-value

A Bayesian analysis will additionally compare the null hypothesis p = 0.20 against an alternative hypothesis using a Beta(1,1) prior.

Power Analysis

The first preregistered confirmatory dataset targets 200 completed anonymous sessions, equivalent to 5,000 completed trials.

At N = 200 completed sessions, equal to 5,000 trials, the study has approximately 80% power to detect an aggregate hit rate of about 21.6% versus the 20% chance expectation. Hit rates of 22% or higher would be detected with greater than 90% power.

Although the primary statistical analysis is performed at the trial level, multiple sessions may originate from the same anonymous participant because no persistent identifiers are collected. Results should therefore be interpreted as trial- and session-level evidence rather than participant-level evidence.

Published Protocol Source

The following protocol-critical source code is published in full. Website administration, authentication, infrastructure, API keys, archive management, and unrelated application code are intentionally omitted because they are not part of the experimental protocol.

Protocol Constants
ZENER_SYMBOLS = {"circle", "cross", "waves", "square", "star"}
ZENER_PROTOCOL_VERSION = "v2.0.0_preregistered"
ZENER_RNG_SOURCE = "python_secrets"
ZENER_TARGET_MODE = "generate_after_guess"
Trial Endpoint
@app.route("/zener-cards/trial", methods=["POST"])
@limiter.limit("300 per hour")
def zener_cards_trial():
    data = request.get_json(silent=True) or {}

    try:
        session_id = str(data.get("session_id", "")).strip()
        trial_number = int(data.get("trial_number"))
        guess = str(data.get("guess", "")).strip()
    except Exception:
        return jsonify({"ok": False, "error": "Invalid trial."}), 400

    if not re.match(r"^[a-f0-9\-]{36}$", session_id):
        return jsonify({"ok": False, "error": "Invalid session."}), 400

    if trial_number < 1 or trial_number > 25:
        return jsonify({"ok": False, "error": "Invalid trial number."}), 400

    if guess not in ZENER_SYMBOLS:
        return jsonify({"ok": False, "error": "Invalid guess."}), 400

    target = secrets.choice(tuple(ZENER_SYMBOLS))
    hit = guess == target

    now = datetime.now(LOCAL_TZ)
    iso_year, iso_week, _ = now.isocalendar()
    session_key = f"{iso_year}-W{iso_week:02d}"

    try:
        supabase.table("zener_trials").insert({
            "session_id": session_id,
            "session_key": session_key,
            "protocol_version": ZENER_PROTOCOL_VERSION,
            "rng_source": ZENER_RNG_SOURCE,
            "target_mode": ZENER_TARGET_MODE,
            "trial_number": trial_number,
            "guess": guess,
            "target": target,
            "hit": hit
        }).execute()

        return jsonify({"ok": True})

    except Exception as e:
        msg = str(e).lower()

        if (
            "duplicate key" in msg
            or "unique constraint" in msg
            or "zener_trials_session_trial_unique" in msg
        ):
            return jsonify({"ok": True})

        print("Zener trial error:", e)
        return jsonify({"ok": False, "error": "Could not log trial."}), 500
Final Submission Endpoint
@app.route("/zener-cards/submit", methods=["POST"])
@limiter.limit("10 per hour")
def zener_cards_submit():
    data = request.get_json(silent=True) or {}
    session_id = str(data.get("session_id", "")).strip()

    if not re.match(r"^[a-f0-9\-]{36}$", session_id):
        return jsonify({"ok": False, "error": "Invalid session."}), 400

    now = datetime.now(LOCAL_TZ)
    iso_year, iso_week, _ = now.isocalendar()
    session_key = f"{iso_year}-W{iso_week:02d}"

    try:
        trials = (
            supabase.table("zener_trials")
            .select("trial_number, hit, created_at")
            .eq("session_id", session_id)
            .execute()
            .data or []
        )

        trial_numbers = {int(t.get("trial_number")) for t in trials}

        if len(trials) != 25 or trial_numbers != set(range(1, 26)):
            return jsonify({"ok": False, "error": "Incomplete session."}), 400

        trial_times = []

        for t in trials:
            created_at = t.get("created_at")
            if created_at:
                trial_times.append(isoparse(created_at))

        duration_seconds = None
        suspiciously_fast = False

        if len(trial_times) == 25:
            first_trial = min(trial_times)
            last_trial = max(trial_times)
            duration_seconds = int((last_trial - first_trial).total_seconds())
            suspiciously_fast = duration_seconds < 20

        existing = (
            supabase.table("zener_results")
            .select("score,total,session_key")
            .eq("session_id", session_id)
            .limit(1)
            .execute()
            .data or []
        )

        if existing:
            score = existing[0]["score"]
        else:
            score = sum(1 for t in trials if t.get("hit") is True)

            supabase.table("zener_results").upsert({
                "session_id": session_id,
                "session_key": session_key,
                "score": score,
                "total": 25,
                "protocol_version": ZENER_PROTOCOL_VERSION,
                "rng_source": ZENER_RNG_SOURCE,
                "target_mode": ZENER_TARGET_MODE,
                "duration_seconds": duration_seconds,
                "suspiciously_fast": suspiciously_fast
            }, on_conflict="session_id").execute()

        rows = (
            supabase.table("zener_results")
            .select("score")
            .eq("session_key", session_key)
            .execute()
            .data or []
        )

        participants = len(rows)
        average_score = round(sum(r["score"] for r in rows) / participants, 2) if participants else 0
        highest_score = max((r["score"] for r in rows), default=0)

        return jsonify({
            "ok": True,
            "score": score,
            "total": 25,
            "session_key": session_key,
            "participants": participants,
            "average_score": average_score,
            "highest_score": highest_score
        })

    except Exception as e:
        print("Zener submit error:", e)
        return jsonify({"ok": False, "error": "Could not submit result."}), 500
Database Schema
Table: zener_trials

id uuid primary key
session_id uuid not null
session_key text not null
protocol_version text not null
rng_source text not null
target_mode text not null
trial_number integer not null
guess text not null
target text not null
hit boolean not null
created_at timestamptz not null default now()

Unique constraint:
(session_id, trial_number)


Table: zener_results

id uuid primary key
session_id uuid unique
session_key text
score integer
total integer
protocol_version text
rng_source text
target_mode text
duration_seconds integer
suspiciously_fast boolean
created_at timestamptz default now()

Unique constraint:
(session_id)

Version History

v2.0.0_preregistered

  • Frozen protocol
  • Public preregistration
  • Public methodology
  • Public source code
  • Server-generated post-guess targets
  • Anonymous timing metadata
  • Database integrity constraints

Future protocol modifications will receive a new version number and will not be combined with data collected under previous versions.

Citation

If referencing this experiment in academic, technical, or public work, please cite the protocol version used to generate the dataset.

Current Protocol Version: v2.0.0_preregistered

← Return to the Zener Card Test