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
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.
- The participant submits a guess.
- The server records the guess.
- The server generates exactly one target using Python's cryptographically secure
secretsmodule. - The generated target is stored server-side.
- 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_atis on or after 2026-06-25 00:00:00 UTC. - Session has a valid server-generated score.
Exclusion Criteria
- Session
created_atis 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