Skip to content
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
220 changes: 212 additions & 8 deletions ai_data_eng_tech_round.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@

import math
from collections import Counter
from datetime import datetime

import pandas as pd
import duckdb
Expand Down Expand Up @@ -100,16 +101,73 @@ def _tuples_match(a, b):
# valid_events - clean records ready for Snowflake
# rejected_events - records that failed, each with a 'rejection_reason' field

REQUIRED_FIELDS = ("event_id", "participant_id", "mission_id", "study_id", "timestamp")


def process_events(raw_events: list[dict]) -> tuple[list[dict], list[dict]]:
"""
Clean and validate raw mission completion events.

Each record is checked against every rule and *all* failures are collected,
so a record that breaks multiple rules reports all of them (easier to debug
than a single first-failure message). Deduplication is by event_id in arrival
order: the first sighting of an id is kept, later copies are rejected as
duplicates. Records with a null/missing event_id can't be deduped, so they're
never treated as duplicates of one another — they fail on the required-field
check instead.

Returns:
valid_events: list of clean records ready for Snowflake
rejected_events: list of records that failed validation, each with a 'rejection_reason' field
"""
# YOUR CODE HERE
pass
valid_events: list[dict] = []
rejected_events: list[dict] = []
seen_event_ids: set = set()

for event in raw_events:
reasons: list[str] = []

# Deduplicate on event_id (only meaningful for non-null ids).
event_id = event.get("event_id")
if event_id is not None and event_id in seen_event_ids:
reasons.append("duplicate event_id")
Comment on lines +132 to +133

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Unhashable IDs abort event batches

event_id values like lists or dicts reach event_id in seen_event_ids, so Python raises TypeError and process_events aborts instead of adding the row to rejected_events — should we validate that event_id is a hashable scalar up front and append an event_id rejection reason first?

Severity

Prompt for AI Agents
Before applying, verify this suggestion against the current code. In
ai_data_eng_tech_round.py around lines 130-163 inside `process_events()`, the
deduplication block does `if event_id is not None and event_id in seen_event_ids:`
without ensuring `event_id` is hashable, so unhashable IDs crash with a TypeError
instead of producing a `rejected_events` entry. Refactor the logic to validate
hashability immediately after `event_id = event.get("event_id")` (e.g., attempt
`hash(event_id)` in a try/except); if it’s unhashable, append a rejection reason like
"unhashable event_id" and do not perform the set membership check or add to
`seen_event_ids`. Only add to `seen_event_ids` after the hashability check succeeds,
keeping the rest of the required-field and timestamp/duration validations unchanged.


# Required fields must be present and non-null.
for field in REQUIRED_FIELDS:
if event.get(field) is None:
reasons.append(f"missing {field}")
Comment on lines +136 to +138

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Blank identifiers enter valid events

Empty required identifiers like event_id: "", participant_id: "", mission_id: "", and study_id: "" pass this check because only None is rejected, so invalid records reach valid_events and can be persisted with unusable IDs — should we reject blank strings explicitly, e.g. with isinstance(value, str) and not value.strip() for these fields?

Severity

Prompt for AI Agents
Before applying, verify this suggestion against the current code. In
ai_data_eng_tech_round.py around lines 136-138 inside `process_events()`, the
required-fields loop only treats `None` as missing (`if event.get(field) is None`), so
blank/whitespace strings like `event_id: ""` incorrectly pass and can be added to
`valid_events`. Refactor this check to also reject values that are strings which are
empty or only whitespace (e.g., `isinstance(value, str) and not value.strip()`), and
append an appropriate rejection reason (e.g., `missing {field}` or `blank {field}`) when
they occur. Update or extend the existing `run_unit_tests()` cases to cover at least one
blank required field to ensure these records are rejected, not persisted as valid.


# Timestamp must be a parseable ISO 8601 datetime. Only checked when present
# (avoids a redundant reason when it's already flagged as missing above).
# datetime.fromisoformat() on Python 3.9/3.10 rejects a trailing 'Z', so we
# normalize it to an explicit UTC offset first.
ts = event.get("timestamp")
if ts is not None:
if not isinstance(ts, str):
reasons.append("invalid timestamp")
else:
try:
datetime.fromisoformat(ts.replace("Z", "+00:00"))
Comment on lines +149 to +150

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Date-only timestamps enter analytics

datetime.fromisoformat() accepts date-only strings as midnight, so this exception-only check lets valid_events copy a date-only timestamp unchanged into mission_events and the Snowflake-ready output, even though Part 1 expects an ISO 8601 datetime — should we require an explicit time component before accepting the record?

Severity web_search

Prompt for AI Agents
Before applying, verify this suggestion against the current code. In
ai_data_eng_tech_round.py around lines 149-152 inside the process_events() timestamp
validation block, fix the bug where datetime.fromisoformat(ts) accepts date-only strings
like "2024-01-15" and turns them into midnight, allowing non-datetime values to flow
into valid_events. Refactor the logic to explicitly require an ISO 8601 datetime format
by checking the raw ts string contains a time component (for example, require a 'T'
separator and a ':' in the time part) and only then attempting fromisoformat
normalization for the trailing 'Z'. Update run_unit_tests() around the process_events
unit tests to add a case with a date-only timestamp and assert it appears as "invalid
timestamp" in rejection_reason.

except ValueError:
reasons.append("invalid timestamp")

# Duration must be a strictly-positive integer. The bool guard matters
# because bool is a subclass of int (True would otherwise pass as 1).
duration = event.get("duration_seconds")
if not (isinstance(duration, int) and not isinstance(duration, bool) and duration > 0):
reasons.append("duration_seconds must be a positive integer")

# Record the id so later copies dedupe against it, whether or not this
# record itself is valid.
if event_id is not None:
seen_event_ids.add(event_id)

if reasons:
rejected_events.append({**event, "rejection_reason": "; ".join(reasons)})
else:
valid_events.append(dict(event))

return valid_events, rejected_events


# ─────────────────────────────────────────────
Expand Down Expand Up @@ -173,6 +231,65 @@ def process_events(raw_events: list[dict]) -> tuple[list[dict], list[dict]]:
print("All Part 1 checks passed!" if all(_p1_checks) else "Some Part 1 checks failed — see ✗ above.")


# ─────────────────────────────────────────────
# UNIT TESTS: process_events() edge cases
# ─────────────────────────────────────────────
# The self-checks above only exercise the one fixed raw_events dataset. These
# assert-based tests cover cases that dataset doesn't reach: empty input, an
# all-valid batch, multi-reason records, dedupe independent of validity, and the
# bool-vs-int and null-event_id edges. Plain asserts keep this dependency-free
# (no pytest needed — the exercise only installs duckdb + pandas).

def run_unit_tests() -> None:
# Empty input -> two empty lists.
assert process_events([]) == ([], [])

# A fully clean batch rejects nothing.
clean = [
{"event_id": "e1", "participant_id": "p1", "mission_id": "m1", "study_id": "s1",
"event_type": "mission_complete", "timestamp": "2024-01-15T10:30:00Z", "duration_seconds": 100},
]
valid, rejected = process_events(clean)
assert len(valid) == 1 and rejected == []

# A record failing multiple checks reports ALL reasons (bad timestamp + bad duration).
valid, rejected = process_events([
{"event_id": "e2", "participant_id": "p1", "mission_id": "m1", "study_id": "s1",
"event_type": "mission_complete", "timestamp": "not-a-timestamp", "duration_seconds": -5},
])
assert valid == [] and len(rejected) == 1
reason = rejected[0]["rejection_reason"]
assert "invalid timestamp" in reason and "positive integer" in reason, reason

# Dedupe is by arrival order and independent of validity: first copy kept, second rejected.
dupe = clean[0]
valid, rejected = process_events([dupe, dict(dupe)])
assert len(valid) == 1 and len(rejected) == 1
assert "duplicate event_id" in rejected[0]["rejection_reason"]

# Two records with a null event_id are NOT duplicates of each other — each fails
# only on the missing-field check, not on dedupe.
null_id = {"event_id": None, "participant_id": "p1", "mission_id": "m1", "study_id": "s1",
"event_type": "mission_complete", "timestamp": "2024-01-15T10:30:00Z", "duration_seconds": 100}
valid, rejected = process_events([dict(null_id), dict(null_id)])
assert valid == [] and len(rejected) == 2
assert all("duplicate event_id" not in r["rejection_reason"] for r in rejected)
assert all("missing event_id" in r["rejection_reason"] for r in rejected)

# Boolean duration is rejected (bool is a subclass of int, so True must not pass as 1).
valid, rejected = process_events([{**clean[0], "event_id": "e3", "duration_seconds": True}])
assert valid == [] and len(rejected) == 1
assert "positive integer" in rejected[0]["rejection_reason"]


print("\n── Unit tests: process_events() ──")
try:
run_unit_tests()
print(" ✓ All process_events() unit tests passed!")
except AssertionError as _e:
print(f" ✗ Unit test failed: {_e}")
Comment on lines +285 to +290

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Unit-test failures are silently ignored

run_unit_tests() catches AssertionError and only prints a message, so the process still exits 0 and regressions in process_events() can slip through CI — should we let the error propagate, or re-raise it after printing?

Severity

Prompt for AI Agents
Before applying, verify this suggestion against the current code. In
ai_data_eng_tech_round.py around lines 285-290, the bottom-of-file unit test harness
wraps run_unit_tests() in a try/except AssertionError that only prints an error and then
allows execution to continue. Change this so test failures fail the run: either remove
the except block entirely or, if you want to keep the custom message, re-raise the
caught AssertionError (after printing) so the process exits non-zero. Ensure the
caller/CI observes the failure from process_events() regressions.



# ─────────────────────────────────────────────
# PART 2: SQL
# ─────────────────────────────────────────────
Expand All @@ -197,19 +314,73 @@ def process_events(raw_events: list[dict]) -> tuple[list[dict], list[dict]]:
con = duckdb.connect()
con.register("mission_events", valid_events_df)

# Q1
# Q1 — Joins & Aggregations
# Each row is one mission_complete event, so COUNT(*) = number of completions per
# study (a single mission_id can be completed by many participants, which is why we
# count events, not DISTINCT mission_id). HAVING drops studies with 2 or fewer.
q1 = """
-- YOUR QUERY HERE
SELECT
study_id,
COUNT(*) AS completed_missions,
AVG(duration_seconds) AS avg_duration_seconds
FROM mission_events
GROUP BY study_id
HAVING COUNT(*) > 2
ORDER BY study_id
"""

# Q2
# Q2 — Window Functions
# Rank each participant's missions by duration (longest first) within each study,
# so the partition is (participant_id, study_id). RANK() leaves gaps on ties; the
# data has no ties, so RANK/DENSE_RANK/ROW_NUMBER coincide here.
q2 = """
-- YOUR QUERY HERE
SELECT
participant_id,
study_id,
mission_id,
duration_seconds,
RANK() OVER (
PARTITION BY participant_id, study_id
ORDER BY duration_seconds DESC
) AS duration_rank
FROM mission_events
ORDER BY participant_id, study_id, duration_rank
"""

# Q3
# Q3 — Anomaly Detection
# Compare each participant's average duration to their study's mean, measured in
# study standard deviations. STDDEV() is sample stddev in DuckDB; NULLIF guards
# against single-row studies where stddev is 0/NULL (avoids divide-by-zero). Only
# rows more than 2 stddev out are returned.
q3 = """
-- YOUR QUERY HERE
WITH participant_avg AS (
SELECT
participant_id,
study_id,
AVG(duration_seconds) AS avg_duration
FROM mission_events
GROUP BY participant_id, study_id
),
study_stats AS (
SELECT
study_id,
AVG(duration_seconds) AS study_mean,
STDDEV(duration_seconds) AS study_stddev
FROM mission_events
GROUP BY study_id
)
SELECT
pa.participant_id,
pa.study_id,
pa.avg_duration,
ss.study_mean,
ss.study_stddev,
ABS(pa.avg_duration - ss.study_mean) / NULLIF(ss.study_stddev, 0) AS stddevs_away
FROM participant_avg pa
JOIN study_stats ss USING (study_id)
WHERE ss.study_stddev > 0
AND ABS(pa.avg_duration - ss.study_mean) / ss.study_stddev > 2
ORDER BY stddevs_away DESC
"""

q1_df = con.execute(q1).df()
Expand Down Expand Up @@ -258,3 +429,36 @@ def process_events(raw_events: list[dict]) -> tuple[list[dict], list[dict]]:
)

print("All Part 2 checks passed!" if _q1_ok and _q2_ok and _q3_ok else "Some Part 2 checks failed — see ✗ above.")


# ─────────────────────────────────────────────
# NOTES: tradeoffs & assumptions
# ─────────────────────────────────────────────
# Part 1 — process_events()
# - Reject reasons are COLLECTED, not short-circuited: a record is run through
# every check and all failures are joined into one rejection_reason (e.g.
# ghi789 -> "invalid timestamp; duration_seconds must be a positive integer").
# Costs a little extra work per bad record but makes pipeline failures fully
# visible/debuggable instead of surfacing one problem at a time.
# - Dedupe is first-wins in arrival order, keyed on event_id, and independent of
# validity. Null/missing event_ids are never deduped against each other — they
# fail the required-field check instead, so two null-id records don't collapse.
# - Timestamps: 'Z' is normalized to '+00:00' before datetime.fromisoformat(),
# which on Python 3.9/3.10 rejects a bare trailing 'Z'. Only ISO 8601 is
# accepted — no attempt to coerce other formats (fail loudly rather than guess).
# - duration_seconds must be a strictly-positive int; bool is explicitly excluded
# since it subclasses int. 0 and floats are rejected (tighten/loosen if the
# real source emits fractional seconds).
# - Original records are shallow-copied into the output lists so the returned
# data doesn't alias the raw input.
#
# Part 2 — SQL
# - Q1 counts completions with COUNT(*), not COUNT(DISTINCT mission_id): each row
# is one participant completing a mission, and the same mission_id can be
# completed by several participants (s2.m3 x3). Counting distinct missions would
# answer a different question and, on this data, drop every study below the
# >2 threshold.
# - Q3 uses DuckDB's STDDEV (sample stddev). With this tiny dataset the result is
# empty under both sample and population stddev (every z-score < 1.5). NULLIF
# guards single-row studies (stddev 0/NULL) against divide-by-zero. At real
# scale, decide deliberately between sample and population stddev.