From 5ecec37f8c1e11b941571c74d4a0b2f46629b37c Mon Sep 17 00:00:00 2001 From: Mona Date: Wed, 12 Aug 2026 15:11:56 -0700 Subject: [PATCH 1/3] feat(schema): emit registry lifecycle flags from slicer_fields.yaml sync_schema.py now collects the `deprecated` and `sunsetted` flags the registry already publishes (session + event, fields + properties) and emits them as DEPRECATED_KEYS / SUNSETTED_KEYS frozensets. _schema.py exposes them normalized as DEPRECATED_REGISTRY_COLUMNS and SUNSETTED_REGISTRY_COLUMNS. This gives the package a generated view of registry lifecycle state that tracks cortex on every schema sync, without moving per-consumer curation into the shared registry. Refs DS-1045 --- scripts/sync_schema.py | 71 ++++++++++++++++++++++++++ src/cognitive3dpy/_schema.py | 19 +++++++ src/cognitive3dpy/_schema_generated.py | 61 ++++++++++++++++++++++ 3 files changed, 151 insertions(+) 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", +}) From ef883a542a56b2e7e9e4731d3333499175b81a92 Mon Sep 17 00:00:00 2001 From: Mona Date: Wed, 12 Aug 2026 15:12:04 -0700 Subject: [PATCH 2/3] feat(sessions): drop registry-deprecated columns from compact output MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SESSIONS_COMPACT_COLUMNS carried two keys the registry marks deprecated: - c3d.metrics.standing_percentage — retired compute, replaced by c3d.metric_components.posture_standing_percentage. New sessions carry only the posture_* trio; the same-named StarRocks column is a melder compatibility alias, and the ES path has no alias at all. The replacement column takes its place in compact output. - c3d.metrics.battery_efficiency — superseded by a drain rate, which has no single successor key. Removed without a replacement column; callers who need it can compute it from battery_drain_sum and battery_drain_time_millis with compact=False. Both keep their DEPRECATED_PROPERTIES entries, so they still emit a DeprecationWarning and remain in non-compact output and empty frames. c3d.metrics.average_fps stays: melder wants to delete the emission but the org dashboard tile still reads it, so the registry status is deliberately active. Noted inline so it is not "cleaned up" by mistake. Refs DS-1045 --- src/cognitive3dpy/_transform.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) 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", From f4fe389b24ee49de6df76bcc3e3cd9cdaf641976 Mon Sep 17 00:00:00 2001 From: Mona Date: Wed, 12 Aug 2026 15:12:14 -0700 Subject: [PATCH 3/3] test: guard compact columns against registry drift MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fails CI when a compact column resolves to no known field/property, or to a key the registry has marked deprecated or sunsetted. Derived columns (API flags, package-computed fields) are allowlisted with reasons, and deliberate retentions go in _LIFECYCLE_EXCEPTIONS — empty today. A stale-exception test keeps that list honest. Closes the loop with the schema-sync workflow: a cortex registry change regenerates the lifecycle sets on the PR branch, and this test turns red if the curated list no longer agrees with it. Refs DS-1045 --- tests/test_compact_columns.py | 112 ++++++++++++++++++++++++++++++++++ 1 file changed, 112 insertions(+) create mode 100644 tests/test_compact_columns.py 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