diff --git a/scripts/sync_schema.py b/scripts/sync_schema.py index 5aaf8e6..c922edb 100644 --- a/scripts/sync_schema.py +++ b/scripts/sync_schema.py @@ -100,6 +100,39 @@ def parse_fields(fields_dict: dict) -> dict[str, str]: return result +def collect_lifecycle(section: dict, grouped: bool) -> tuple[set[str], set[str]]: + """Collect keys carrying the registry lifecycle flags. + + The YAML marks two orthogonal facts (see the slicer_fields.yaml header): + ``deprecated`` is a consumption-side intent (stop reading this key) and + ``sunsetted`` is a production-side fact (no shipping SDK emits it). + + *grouped* is True for property sections, which nest their entries one + level deeper under a type section (textual/numerical/boolean). + Returns ``(deprecated_keys, sunsetted_keys)``. + """ + deprecated: set[str] = set() + sunsetted: set[str] = set() + + def scan(entries: dict) -> None: + for key, meta in entries.items(): + if not isinstance(meta, dict): + continue + if meta.get("deprecated"): + deprecated.add(key) + if meta.get("sunsetted"): + sunsetted.add(key) + + if grouped: + for section_entries in section.values(): + if isinstance(section_entries, dict): + scan(section_entries) + else: + scan(section) + + return deprecated, sunsetted + + def parse_properties(properties_dict: dict) -> dict[str, str]: """Parse a session_properties or event_properties section. @@ -143,6 +176,17 @@ def format_dict( return "\n".join(lines) +def format_set(name: str, entries: set[str], indent: str = " ") -> str: + """Format a set of keys as a frozenset constant declaration.""" + if not entries: + return f"{name}: frozenset[str] = frozenset()" + + lines = [f"{name}: frozenset[str] = frozenset({{"] + lines.extend(f'{indent}"{key}",' for key in sorted(entries)) + lines.append("})") + return "\n".join(lines) + + def generate(yaml_path: Path) -> str: """Read the YAML and produce the generated module source.""" with open(yaml_path) as f: @@ -153,6 +197,20 @@ def generate(yaml_path: Path) -> str: event_fields = parse_fields(data.get("event_fields", {})) event_properties = parse_properties(data.get("event_properties", {})) + # Lifecycle flags across every section — session and event, fields and + # properties — collapsed into two flat sets of raw registry keys. + deprecated_keys: set[str] = set() + sunsetted_keys: set[str] = set() + for section_name, grouped in ( + ("session_fields", False), + ("event_fields", False), + ("session_properties", True), + ("event_properties", True), + ): + dep, sun = collect_lifecycle(data.get(section_name, {}), grouped) + deprecated_keys |= dep + sunsetted_keys |= sun + yaml_name = yaml_path.name yaml_hash = hashlib.sha256(yaml_path.read_bytes()).hexdigest()[:12] @@ -203,6 +261,19 @@ def generate(yaml_path: Path) -> str: "", format_dict("EVENT_PROPERTY_TYPES", event_properties), "", + "", + "# " + "=" * 77, + "# LIFECYCLE FLAGS", + "# Raw registry keys (session + event, fields + properties) carrying", + '# "deprecated: true" (consumers should stop reading the key) or', + '# "sunsetted: true" (no shipping SDK emits it). The two compose freely.', + "# " + "=" * 77, + "", + format_set("DEPRECATED_KEYS", deprecated_keys), + "", + "", + format_set("SUNSETTED_KEYS", sunsetted_keys), + "", ] return "\n".join(sections) diff --git a/src/cognitive3dpy/_schema.py b/src/cognitive3dpy/_schema.py index e04c344..9e58a40 100644 --- a/src/cognitive3dpy/_schema.py +++ b/src/cognitive3dpy/_schema.py @@ -18,8 +18,10 @@ import polars as pl from cognitive3dpy._schema_generated import ( + DEPRECATED_KEYS, SESSION_FIELD_TYPES, SESSION_PROPERTY_TYPES, + SUNSETTED_KEYS, ) from cognitive3dpy._transform import _clean_name @@ -76,6 +78,12 @@ "c3d.metrics.controller_events_score": None, "c3d.metrics.controller_engagement_score": None, "c3d.metrics.dynamic_engagement_score": None, + "c3d.metrics.standing_percentage": + "c3d.metric_components.posture_standing_percentage", + # Superseded by a drain *rate*, which has no single successor key — + # compute it from c3d.metric_components.battery_drain_sum and + # c3d.metric_components.battery_drain_time_millis (compact=False). + "c3d.metrics.battery_efficiency": None, } # Renamed properties: old_name → new_name (same data, new path) @@ -106,6 +114,17 @@ # Top-level fields that are already normalized (not c3d.* properties) DEPRECATED_COLUMNS["hmd"] = "c3d_device_hmd_type" +# Registry lifecycle flags, normalized to column names. Unlike the curated +# maps above these are generated from slicer_fields.yaml, so they track the +# registry on every schema sync. Used to guard SESSIONS_COMPACT_COLUMNS +# against drift (see tests/test_compact_columns.py). +DEPRECATED_REGISTRY_COLUMNS: frozenset[str] = frozenset( + _clean_name(k) for k in DEPRECATED_KEYS +) +SUNSETTED_REGISTRY_COLUMNS: frozenset[str] = frozenset( + _clean_name(k) for k in SUNSETTED_KEYS +) + RENAMED_COLUMNS: dict[str, str] = { _clean_name(k): _clean_name(v) for k, v in RENAMED_PROPERTIES.items() diff --git a/src/cognitive3dpy/_schema_generated.py b/src/cognitive3dpy/_schema_generated.py index 567836d..0f14eb1 100644 --- a/src/cognitive3dpy/_schema_generated.py +++ b/src/cognitive3dpy/_schema_generated.py @@ -300,3 +300,64 @@ "questionSetId": pl.Utf8, "hook": pl.Utf8, } + + +# ============================================================================= +# LIFECYCLE FLAGS +# Raw registry keys (session + event, fields + properties) carrying +# "deprecated: true" (consumers should stop reading the key) or +# "sunsetted: true" (no shipping SDK emits it). The two compose freely. +# ============================================================================= + +DEPRECATED_KEYS: frozenset[str] = frozenset({ + "c3d.device.eyetracking.type", + "c3d.height", + "c3d.metric_components.forward_reach_score", + "c3d.metric_components.fps_data_point_count", + "c3d.metric_components.fps_data_point_sum", + "c3d.metric_components.horizontal_reach_score", + "c3d.metric_components.pitch_score", + "c3d.metric_components.roll_score", + "c3d.metric_components.vertical_reach_score", + "c3d.metrics.app_performance", + "c3d.metrics.battery_efficiency", + "c3d.metrics.boundary_score", + "c3d.metrics.controller_engagement_score", + "c3d.metrics.controller_ergonomic_score", + "c3d.metrics.controller_events_score", + "c3d.metrics.dynamic_engagement_score", + "c3d.metrics.ergonomics_score", + "c3d.metrics.head_orientation_score", + "c3d.metrics.immersion_score", + "c3d.metrics.orientation_score", + "c3d.metrics.standing_percentage", + "c3d.participant.hmdHeight", + "c3d.roomsize", +}) + + +SUNSETTED_KEYS: frozenset[str] = frozenset({ + "c3d.app.androidPlugin.hostName", + "c3d.app.androidPlugin.networkHostName", + "c3d.app.multiplayer.lobbyId", + "c3d.app.plugin.version", + "c3d.device.manufacturer", + "c3d.device.screenresolution", + "c3d.device.serial_number", + "c3d.device.serialnumber", + "c3d.headphonespresent", + "c3d.height", + "c3d.oculusId", + "c3d.participant.Age", + "c3d.participant.Color", + "c3d.participant.Job", + "c3d.participant.Sex", + "c3d.participant.hmdHeight", + "c3d.roomscale", + "cvr.device.graphics.memory", + "cvr.device.graphics.version", + "cvr.device.platform", + "cvr.vr.display.family", + "cvr.vr.display.model", + "cvr.vr.enabled", +}) diff --git a/src/cognitive3dpy/_transform.py b/src/cognitive3dpy/_transform.py index a324516..be25c43 100644 --- a/src/cognitive3dpy/_transform.py +++ b/src/cognitive3dpy/_transform.py @@ -94,11 +94,12 @@ def _clean_name(name: str) -> str: "c3d_roomsize_meters", # Key metrics (top-level scores) "c3d_metrics_fps_score", + # Registry status is deliberately "active" despite melder's deletion + # intent — see cvr-cortex investigations/average-fps-intended-deletion- + # vs-live-consumer.md. Do not drop this without revisiting that note. "c3d_metrics_average_fps", "c3d_metrics_presence_score", "c3d_metrics_comfort_score", - "c3d_metrics_battery_efficiency", - "c3d_metrics_standing_percentage", "c3d_metrics_cyberwellness_score", # Metric components (sub-scores) "c3d_metric_components_fps_score_degree_app_performance", @@ -121,6 +122,8 @@ def _clean_name(name: str) -> str: "c3d_metric_components_cyberwellness_translational_movement", "c3d_metric_components_cyberwellness_translational_speed", "c3d_metric_components_cyberwellness_continuous_movement", + # Replaces the retired c3d.metrics.standing_percentage compute. + "c3d_metric_components_posture_standing_percentage", # Controller ergo counts "c3d_metric_components_controller_ergo_counts_forwards_near", "c3d_metric_components_controller_ergo_counts_forwards_medium", diff --git a/tests/test_compact_columns.py b/tests/test_compact_columns.py new file mode 100644 index 0000000..0a1ebdf --- /dev/null +++ b/tests/test_compact_columns.py @@ -0,0 +1,112 @@ +"""Guard SESSIONS_COMPACT_COLUMNS against drift from the cortex registry. + +``SESSIONS_COMPACT_COLUMNS`` is a hand-curated list — the registry does not +carry per-consumer presentation intent, so it cannot be generated. What the +registry *does* carry is lifecycle state (``deprecated`` / ``sunsetted``), +which ``sync_schema.py`` emits into ``_schema_generated.py``. These tests use +it to fail CI when the curated list falls out of step with the registry: + +- a column resolves to no known field/property at all, or +- a column resolves to a key the registry has retired. + +When a schema sync turns one of these red, either drop the column (following +the deprecation convention in CLAUDE.md) or add it to +``_LIFECYCLE_EXCEPTIONS`` with a reason. +""" + +from __future__ import annotations + +from cognitive3dpy._schema import ( + DEPRECATED_REGISTRY_COLUMNS, + SESSION_SCHEMA, + SUNSETTED_REGISTRY_COLUMNS, +) +from cognitive3dpy._transform import SESSIONS_COMPACT_COLUMNS + +# Compact columns with no registry key behind them. Each is produced by the +# package or returned by the API under a name the registry does not model, +# so the resolution check can never cover them. +_DERIVED_COMPACT_COLUMNS: dict[str, str] = { + # API response fields absent from slicer_fields.yaml + "device_id": "API session field, not modelled in the registry", + "user_key": "API session field, not modelled in the registry", + # Data-availability flags. The registry models the ES-side plural names + # (hasGazes, hasEvents, ...); the list API returns these singular ones, + # and has_boundary has no registry counterpart at all. + "has_gaze": "API list-response flag; registry models hasGazes", + "has_fixation": "API list-response flag; registry models hasFixations", + "has_event": "API list-response flag; registry models hasEvents", + "has_dynamic": "API list-response flag; registry models hasDynamics", + "has_sensor": "API list-response flag; registry models hasSensors", + "has_boundary": "API list-response flag; no registry counterpart", +} + +# Registry-retired columns deliberately kept in the compact output. +# Key: column name. Value: why it stays. +_LIFECYCLE_EXCEPTIONS: dict[str, str] = {} + + +def test_no_duplicate_compact_columns(): + duplicates = sorted( + {c for c in SESSIONS_COMPACT_COLUMNS if SESSIONS_COMPACT_COLUMNS.count(c) > 1} + ) + assert not duplicates, f"Duplicate compact columns: {duplicates}" + + +def test_every_compact_column_resolves(): + """Each column maps to a schema entry or a documented derived column.""" + unresolved = [ + c + for c in SESSIONS_COMPACT_COLUMNS + if c not in SESSION_SCHEMA and c not in _DERIVED_COMPACT_COLUMNS + ] + assert not unresolved, ( + f"Compact columns resolve to no known field or property: {unresolved}. " + "Either the name is stale, or add it to _DERIVED_COMPACT_COLUMNS " + "with a reason." + ) + + +def test_no_deprecated_columns_in_compact(): + """Registry-deprecated keys must not sit in the default output.""" + offenders = sorted( + (set(SESSIONS_COMPACT_COLUMNS) & DEPRECATED_REGISTRY_COLUMNS) + - set(_LIFECYCLE_EXCEPTIONS) + ) + assert not offenders, ( + f"Compact columns are deprecated in the registry: {offenders}. " + "Drop them per the deprecation convention in CLAUDE.md, or add them " + "to _LIFECYCLE_EXCEPTIONS with a reason." + ) + + +def test_no_sunsetted_columns_in_compact(): + """Nothing in the default output should be a key no SDK still emits.""" + offenders = sorted( + (set(SESSIONS_COMPACT_COLUMNS) & SUNSETTED_REGISTRY_COLUMNS) + - set(_LIFECYCLE_EXCEPTIONS) + ) + assert not offenders, ( + f"Compact columns are sunsetted in the registry: {offenders}. " + "Drop them, or add them to _LIFECYCLE_EXCEPTIONS with a reason." + ) + + +def test_exceptions_are_still_needed(): + """An exception whose column is gone or no longer flagged is stale.""" + flagged = DEPRECATED_REGISTRY_COLUMNS | SUNSETTED_REGISTRY_COLUMNS + stale = sorted( + c + for c in _LIFECYCLE_EXCEPTIONS + if c not in SESSIONS_COMPACT_COLUMNS or c not in flagged + ) + assert not stale, f"Stale entries in _LIFECYCLE_EXCEPTIONS: {stale}" + + +def test_deprecated_columns_have_migration_entries(): + """Columns dropped from compact still warn via handle_deprecated_columns.""" + from cognitive3dpy._schema import DEPRECATED_COLUMNS + + for column in ("c3d_metrics_standing_percentage", "c3d_metrics_battery_efficiency"): + assert column not in SESSIONS_COMPACT_COLUMNS + assert column in DEPRECATED_COLUMNS