File size: 7,402 Bytes
c1dcc23
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
"""Validate an event-log JSONL file against the official sample_events.jsonl schema.

The challenge's held-out set (and our submitted event log) uses a multi-source
schema with three event families:

  entry / exit                  -> id_token, store_code, event_timestamp, is_staff, demographics
  zone_entered / zone_exited    -> track_id, store_id, zone_id, event_time, zone_* , hotspots
  queue_completed / queue_abandoned -> queue_event_id, queue_*_ts, wait_seconds, abandoned

Usage:
    python scripts/validate_events.py data/sample_events.jsonl [more.jsonl ...]

Exit code is non-zero if any HARD error is found (bad JSON, unknown event_type,
missing required field, unparseable timestamp). "Recommended" fields that the
official sample always carries (camera_id, demographics, zone hotspots, ...) are
reported as WARNINGS only β€” they enrich the stream but are not required to parse.
"""
from __future__ import annotations

import json
import sys
from datetime import datetime
from pathlib import Path

# Windows consoles default to cp1252 and choke on the βœ“/⚠/βœ— glyphs below.
try:
    sys.stdout.reconfigure(encoding="utf-8")
except (AttributeError, ValueError):
    pass

OFFICIAL_TYPES = {
    "entry", "exit", "zone_entered", "zone_exited",
    "queue_completed", "queue_abandoned",
}

# required = must be present & non-null to be a valid event of this family
# recommended = present in the official sample; absence is a warning, not an error
SCHEMA = {
    "entry": {
        "required": {"event_type", "id_token", "store_code", "event_timestamp", "is_staff"},
        "recommended": {"camera_id", "gender_pred", "age_pred", "age_bucket",
                        "is_face_hidden", "group_id", "group_size"},
        "ts": ["event_timestamp"],
    },
    "exit": {
        "required": {"event_type", "id_token", "store_code", "event_timestamp", "is_staff"},
        "recommended": {"camera_id", "gender_pred", "age_pred", "age_bucket",
                        "is_face_hidden", "group_id", "group_size"},
        "ts": ["event_timestamp"],
    },
    "zone_entered": {
        "required": {"event_type", "store_id", "zone_id", "event_time"},
        "id_one_of": {"track_id", "id_token"},
        "recommended": {"camera_id", "zone_name", "zone_type", "is_revenue_zone",
                        "zone_hotspot_x", "zone_hotspot_y", "gender", "age", "age_bucket"},
        "ts": ["event_time"],
    },
    "zone_exited": {
        "required": {"event_type", "store_id", "zone_id", "event_time"},
        "id_one_of": {"track_id", "id_token"},
        "recommended": {"camera_id", "zone_name", "zone_type", "is_revenue_zone",
                        "zone_hotspot_x", "zone_hotspot_y", "gender", "age", "age_bucket"},
        "ts": ["event_time"],
    },
    "queue_completed": {
        "required": {"queue_event_id", "event_type", "store_id", "zone_id",
                     "queue_join_ts", "abandoned"},
        "id_one_of": {"track_id", "id_token"},
        "recommended": {"camera_id", "zone_name", "zone_type", "is_revenue_zone",
                        "queue_served_ts", "queue_exit_ts", "wait_seconds",
                        "queue_position_at_join", "zone_hotspot_x", "zone_hotspot_y",
                        "gender", "age", "age_bucket"},
        "ts": ["queue_join_ts"],
    },
    "queue_abandoned": {
        "required": {"queue_event_id", "event_type", "store_id", "zone_id",
                     "queue_join_ts", "abandoned"},
        "id_one_of": {"track_id", "id_token"},
        "recommended": {"camera_id", "zone_name", "zone_type", "is_revenue_zone",
                        "queue_served_ts", "queue_exit_ts", "wait_seconds",
                        "queue_position_at_join", "zone_hotspot_x", "zone_hotspot_y",
                        "gender", "age", "age_bucket"},
        "ts": ["queue_join_ts"],
    },
}


def _parse_ts(v: object) -> bool:
    if not isinstance(v, str) or not v:
        return False
    try:
        datetime.fromisoformat(v.replace("Z", "+00:00"))
        return True
    except ValueError:
        return False


def validate_file(path: Path) -> tuple[int, int, int]:
    """Returns (n_events, n_errors, n_warnings) and prints a report for `path`."""
    errors: list[str] = []
    warnings: list[str] = []
    type_counts: dict[str, int] = {}
    missing_recommended: dict[str, int] = {}
    n = 0

    if not path.exists():
        print(f"  βœ— file not found: {path}")
        return (0, 1, 0)

    for lineno, raw in enumerate(path.read_text(encoding="utf-8").splitlines(), 1):
        if not raw.strip():
            continue
        n += 1
        try:
            ev = json.loads(raw)
        except json.JSONDecodeError as exc:
            errors.append(f"line {lineno}: invalid JSON ({exc})")
            continue
        if not isinstance(ev, dict):
            errors.append(f"line {lineno}: not a JSON object")
            continue

        et = ev.get("event_type")
        type_counts[et] = type_counts.get(et, 0) + 1
        if et not in OFFICIAL_TYPES:
            errors.append(f"line {lineno}: unknown event_type {et!r} "
                          f"(expected one of {sorted(OFFICIAL_TYPES)})")
            continue

        rules = SCHEMA[et]
        for f in rules["required"]:
            if f not in ev or ev[f] is None:
                errors.append(f"line {lineno} [{et}]: missing required field {f!r}")
        if "id_one_of" in rules and not (rules["id_one_of"] & {k for k, v in ev.items() if v is not None}):
            errors.append(f"line {lineno} [{et}]: needs one of "
                          f"{sorted(rules['id_one_of'])} for visitor identity")
        for f in rules["ts"]:
            if f in ev and ev[f] is not None and not _parse_ts(ev[f]):
                errors.append(f"line {lineno} [{et}]: unparseable timestamp {f}={ev[f]!r}")
        for f in rules["recommended"]:
            if f not in ev:
                missing_recommended[f] = missing_recommended.get(f, 0) + 1

    # roll missing-recommended into warnings (one line per field)
    for f, c in sorted(missing_recommended.items()):
        warnings.append(f"recommended field {f!r} absent on {c} event(s)")

    print(f"\n{'='*70}\n{path}")
    print(f"  events: {n}   types: " +
          ", ".join(f"{k}={v}" for k, v in sorted(type_counts.items(), key=lambda x: str(x[0]))))
    if errors:
        print(f"  βœ— {len(errors)} ERROR(S):")
        for e in errors[:25]:
            print(f"      - {e}")
        if len(errors) > 25:
            print(f"      … and {len(errors) - 25} more")
    else:
        print("  βœ“ no hard errors β€” valid JSONL, all events match the official schema")
    if warnings:
        print(f"  ⚠ {len(warnings)} warning(s) (enrichment fields the sample carries):")
        for w in warnings:
            print(f"      - {w}")
    return (n, len(errors), len(warnings))


def main(argv: list[str]) -> int:
    files = argv or ["data/sample_events.jsonl"]
    total_err = 0
    total_events = 0
    for f in files:
        n, e, _w = validate_file(Path(f))
        total_events += n
        total_err += e
    print(f"\n{'='*70}\nTOTAL: {total_events} events across {len(files)} file(s), "
          f"{total_err} hard error(s) β†’ {'PASS βœ“' if total_err == 0 else 'FAIL βœ—'}")
    return 0 if total_err == 0 else 1


if __name__ == "__main__":
    raise SystemExit(main(sys.argv[1:]))