Skip to content
Merged
Show file tree
Hide file tree
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
71 changes: 71 additions & 0 deletions scripts/sync_schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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:
Expand All @@ -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]

Expand Down Expand Up @@ -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)
Expand Down
19 changes: 19 additions & 0 deletions src/cognitive3dpy/_schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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()
Expand Down
61 changes: 61 additions & 0 deletions src/cognitive3dpy/_schema_generated.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
})
7 changes: 5 additions & 2 deletions src/cognitive3dpy/_transform.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand Down
112 changes: 112 additions & 0 deletions tests/test_compact_columns.py
Original file line number Diff line number Diff line change
@@ -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
Loading