# CLAUDE.md

This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.

**Note to Claude Code**: This is a living document. As you learn new patterns, conventions, or practices while working with the user, or when changes are made that should be reflected here, proactively update this CLAUDE.md to keep it current and useful.

## Radiens-Core - Modern Python Client

Type-safe Python client for the Radiens electrophysiology analytics platform.

See `README-DEV.md` for detailed developer documentation.

## Documentation & Privacy

The `radiens-core` package is open-source, but it interfaces with proprietary Radiens systems. We maintain a strict boundary between public-facing documentation and internal developer information.

- **Internal Documentation**: Files containing internal roadmaps, implementation status, or proprietary design details (e.g., `IMPLEMENTATION_STATUS.md`) must live in the project root, **NOT** in the `docs/` folder. This prevents them from being compiled into the public-facing static site.
- **Source Code Exposure**: The `mkdocs.yml` is configured with `show_source: false` in the `mkdocstrings` plugin. This ensures that while the API reference (docstrings) is visible, the full source code is not embedded directly into the documentation pages.
- **Internal Modules**: Modules or packages starting with an underscore (e.g., `_converters`, `_transport`) are considered internal and are explicitly excluded from the auto-generated documentation via `gen_ref_pages.py`.
- **Repository Links**: Public-facing documentation should not contain direct links to the private GitHub repository or internal CI/CD pipelines.
- **Inline Examples**: `docs/examples.md` embeds tutorial code using `pymdownx.snippets` (`--8<-- "tutorials/filename.py"`). Do **not** replace these with GitHub links — the repo is private.
- **Default Theme**: Documentation defaults to Dark Mode (`slate` theme) for a modern aesthetic, with a manual toggle for Light Mode.

### MkDocs Configuration Notes

- `show_root_heading: false` in `mkdocstrings` — the generated reference `.md` files already provide an H1 via `gen_ref_pages.py`; enabling this would produce a duplicate heading.
- `docs/index.md` and `docs/changelog.md` must **not** have their own H1 — the included files (`README.md`, `CHANGELOG.md`) already provide it. Any `docs/*.md` that uses `--8<--` to include a file starting with an H1 must omit its own H1.
- No `logo:` key in `mkdocs.yml` — the logo asset doesn't exist yet. Only `favicon` is set.
- `gen_ref_pages.py` must **not** call `mkdocs_gen_files.set_edit_path` — there is no `repo_url` configured, and adding edit links would expose the private repo URL if one were added later.

## Key Technologies

- Python 3.12+
- gRPC (communication with backend)
- Pydantic v2 (domain models)
- mypy strict mode (type safety)
- uv (package manager)

## Development Commands

### Environment Setup

```bash
# Install dependencies (creates .venv)
uv sync
```

### After Every Significant Change (REQUIRED)

Run the automated check-and-fix script to auto-fix formatting and lint issues before reviewing your work:

```bash
./scripts/check-fix.sh
```

Alternatively, run individual commands:

```bash
uv run ruff check --fix src/ tests/  # Auto-fix imports and lint issues
uv run ruff format src/ tests/        # Format code (like black)
```

### Pre-Commit Checklist (REQUIRED)

Run the automated check-and-fix script before every commit:

```bash
./scripts/check-fix.sh
```

This script runs the following four checks in sequence:

1. `uv run ruff check --fix src/ tests/`  # Auto-fix lint issues first
2. `uv run ruff format src/ tests/`        # Format code
3. `uv run mypy --strict src/ tests/`      # Type checking (THE critical gate)
4. `uv run pytest tests/unit/ -v`          # Unit tests

### Publishing to PyPI

```bash
# 1. Update version in pyproject.toml
# 2. Run pre-commit checks
# 3. Build and publish:
rm -rf dist/ build/
uv build
uv run twine upload --repository radiens-core dist/*
```

## Architecture

**Proto-driven type safety** is the core design principle.

### Layer Structure

```text
src/radiens_core/
  __init__.py                 # Public API surface
  exceptions.py               # Exception hierarchy
  _constants.py               # Ports, limits, defaults
  _base_client.py             # Base class (channel pool, auth, lifecycle)
  models/                     # Pydantic v2, ZERO proto dependency
  _transport/                 # gRPC channel pool, auth, discovery, error mapping
  _converters/                # Proto <-> domain (primary place importing both)
  _services/                  # RPC wrappers (domain in, domain out)
  _base_file_client.py        # Shared base for file-based clients (Videre, Curate)
  allego_client.py            # Real-time client
  videre_client.py            # Offline file client (extends BaseFileClient)
  curate_client.py            # Curation client (extends BaseFileClient)
  _generated/                 # Committed proto stubs (DO NOT EDIT)
```

### Layer Import Rules

| Layer | Can import from |
| --- | --- |
| `models/` | stdlib, pydantic, numpy |
| `_converters/` | `models/`, `_generated/` |
| `_services/` | `_converters/`, `_generated/`, `_transport/`, `models/` |
| `_base_client.py` | `_transport/` |
| Client classes | `_services/`, `_transport/`, `_generated/`, `models/` |

**Critical Rule:** Proto types are never exposed in the public API. Clients may import from `_generated/` to build proto request objects internally, but all public method signatures use domain models only. `_converters/` is the canonical place for proto ↔ domain translation.

## Type Safety Workflow

The type system is the change-detection mechanism:

**The chain:** Proto `.pyi` stubs → `_converters/` → `models/` → `_services/` → clients

When protos change:

1. Regenerate proto stubs in radix repo (with `mypy-protobuf`)
2. Copy updated `_generated/` files here
3. Run `uv run mypy --strict src/` - shows exactly what broke
4. Fix `_converters/` first, then follow type errors outward
5. Commit updated `_generated/` alongside your fixes

## Code Conventions

### Imports

Always place imports at the top of the file — never inside functions, methods, or conditional blocks. This applies to all imports including stdlib, third-party, and local.

```python
# Correct: imports at top of file
from radiens_core.models.signal import SignalData

def process(data: SignalData) -> None:
    ...

# Wrong: import inside function
def process() -> None:
    from radiens_core.models.signal import SignalData  # Never do this
    ...
```

The only exception is `TYPE_CHECKING` guards, which are still at the top of the file.

### All Files

```python
from __future__ import annotations  # Required in ALL files

from typing import TYPE_CHECKING

if TYPE_CHECKING:
    # Type-only imports here
    from some_module import SomeType
```

### Pydantic Models

```python
from pydantic import BaseModel, ConfigDict

class SignalData(BaseModel):  # type: ignore[explicit-any]
    """All models need this ignore for BaseModel."""
    model_config = ConfigDict(frozen=True)  # Immutable

    timestamp: float
    values: list[float]
```

**Why the ignore?** Pydantic's `BaseModel` uses `Any` internally, conflicting with `disallow_any_explicit = true`. The ignore is surgical and intentional.

### Converters

```python
# _converters/ - ONLY place importing both proto and domain
from radiens_core._generated import signal_pb2
from radiens_core.models.signal import SignalData

def proto_to_domain(proto: signal_pb2.Signal) -> SignalData:
    """Convert proto to domain model."""
    return SignalData(
        timestamp=proto.timestamp,
        values=list(proto.values),
    )
```

### Flex Types & `@validate_call`

Flex types are `Annotated` aliases that accept multiple input forms and coerce them at runtime via `BeforeValidator`. The three Flex types in this codebase are:

| Type | Module | Accepts |
| --- | --- | --- |
| `FlexTimeRange` | `models.dataset` | `TimeRangeSpec \| list \| tuple` |
| `FlexSignalSpec` | `models.signals` | `SignalSpec \| list[int] \| dict` |
| `FlexDataset` | `models.dataset` | `DatasetMetadata \| str \| Path` |

**Import placement depends on whether the file uses `@validate_call`:**

- **With `@validate_call`:** import at **top level** — Pydantic needs the type at runtime for coercion.
- **Without `@validate_call`:** import inside **`TYPE_CHECKING`** — ruff TC001 enforces this.

`pyproject.toml` is configured with `runtime-evaluated-decorators = ["pydantic.validate_call"]` so ruff allows top-level Flex imports in files that use the decorator.

Use `typing.cast` with a string literal to satisfy mypy, which cannot see Pydantic's runtime coercion:

```python
from radiens_core.models.dataset import FlexTimeRange  # top-level: file uses @validate_call

@validate_call(config=ConfigDict(arbitrary_types_allowed=True))
def get_data(self, time_range: FlexTimeRange) -> Data:
    tr_spec = cast("TimeRangeSpec", time_range)  # cast satisfies mypy
    ...
```

### Converters — Builder Functions

Two naming conventions in `_converters/`:

- `*_to_proto(domain_model)` — converts a full domain model to a proto message.
- `build_*_proto(param1, param2, ...)` — builds a proto message from individual parameters, used when the client has no domain model to pass.

```python
# Converting a domain model (standard case)
def recording_config_to_proto(config: RecordingConfig) -> allegoserver_pb2.RecordingConfig:
    ...

# Building from individual args (ergonomic client methods)
def build_update_core_config_proto(
    *,
    samp_freq: float | None = None,
    cable_delay: CableDelay | None = None,
) -> allegoserver_pb2.SetConfigCoreRequest:
    ...
```

### API Design Conventions

#### Keyword-only arguments

Use `*` to force keyword-only for any parameter that is not self-evident from position, or where argument order could cause silent bugs:

```python
def set_dsp_group(self, dataset: FlexDataset, *, stage: FilterStage, params: list[DSPParams]) -> None:
    ...
```

#### Naming: `update_` vs `set_`

Use `update_` (not `set_`) for methods that send a **partial update** to the server — i.e., the server merges supplied fields and leaves others unchanged. Reserve `set_` only for full replacement semantics.

#### Deprecation pattern

When replacing a method, keep the old one as a deprecated shim that delegates to the new one:

```python
def set_recording_config(self, config: RecordingConfig) -> None:
    """...
    Deprecated:
        Use ``update_recording_config()`` instead.
    ...
    """
    warnings.warn(
        "set_recording_config() is deprecated. Use update_recording_config() instead.",
        DeprecationWarning,
        stacklevel=2,
    )
    self.update_recording_config(config)
```

#### Split boolean methods

Avoid `set_X(*, enabled: bool)` in the public API. Instead, expose intent-named methods:

```python
# Prefer this:
def enable_trigger(self, *, channel: TriggerChannel, ...) -> None: ...
def disable_trigger(self, *, channel: TriggerChannel, ...) -> None: ...
def disable_all_triggers(self) -> None: ...

# Over this:
def set_trigger_state(self, *, channel: TriggerChannel, enabled: bool, ...) -> None: ...
```

Use a dedicated `disable_all_*()` method for backend sentinel values (e.g., `T_NONE`) rather than exposing the sentinel in the public channel type.

#### Optional parameters tied to backend mode

Before making a parameter required, check the Go server handler to understand whether the parameter is actually used in all modes. If the backend ignores a value in common configurations (e.g., `port` is ignored in non-split-ports mode), default it to `None` rather than forcing callers to always supply it.

## Testing

```bash
# Run unit tests
uv run pytest tests/unit/ -v

# Run specific test file
uv run pytest tests/unit/test_converters.py -v

# Run with coverage
uv run pytest tests/unit/ --cov=radiens_core --cov-report=html
```

## Editor Setup (VS Code)

**Required extensions:**

- Pylance (type checking)
- Ruff (linting + formatting)

**Recommended extensions:**

- Mypy Type Checker (real-time type checking)

**Settings** (see README-DEV.md for full `.vscode/settings.json`)

## Important Notes

- **mypy strict is non-negotiable**: All code must pass `mypy --strict`
- **Type errors block commits**: Fix all mypy errors before committing
- **Proto changes cascade**: Type system catches all breaking changes
- **Immutable models**: All Pydantic models use `frozen=True`
- **`_generated/` is committed**: Proto stubs are version controlled
- **Layer boundaries are strict**: Follow import rules precisely

## Protobuf Updates

Proto generation is owned by the `core_packages/radix` repo.

When you receive updated proto files:

1. Copy new `_generated/` files
2. Run `uv run mypy --strict src/`
3. mypy shows exactly what needs fixing
4. Fix converters first (they import both proto and domain)
5. Follow type errors through services to clients
6. Commit `_generated/` changes with your fixes

## Changelog

`CHANGELOG.md` lives at the project root and is surfaced in the MkDocs docs site via `docs/changelog.md`.

### When to Update

Any PR or commit that adds a feature, fixes a bug, or makes a user-visible change must update the `[Unreleased]` section in `CHANGELOG.md`.

**Important: The Changelog is Public-Facing.**

- **Do NOT** include changes to internal development tools or project-guidance files (e.g., `CLAUDE.md`, `GEMINI.md`, `AGENTS.md`, `.gitignore`, `uv.lock`).
- **Do NOT** include references to internal-only documentation (e.g., `IMPLEMENTATION_STATUS.md`, `README-DEV.md`).
- **Focus on User Impact:** Describe changes in terms of how they affect the library's users (new methods, fixed bugs, changed model fields).

### Format

Use `Added`, `Changed`, `Fixed`, or `Removed` sections — omit empty ones.

For major releases that consolidate pre-release work, organize `Added` entries by client/area (e.g., `#### AllegoClient`, `#### Domain Models`, `#### Infrastructure`) rather than listing everything flat.

### Beta Consolidation

When promoting from pre-release to stable (e.g., `0.0.0b1`–`0.0.0b9` → `0.0.1`), collapse all beta entries into a single release entry. Users installing the stable release never saw the betas — the granular beta history is noise for them. The stable entry should be the cumulative set of changes, organized by area.

### Release Workflow

When bumping the version in `pyproject.toml`:

1. Rename `[Unreleased]` to `[x.y.z] - YYYY-MM-DD`.
2. Add a fresh `[Unreleased]` section above it with the placeholder `*No unreleased changes.*`.
3. Add a comparison link at the bottom for the new version.
4. Review `README.md` — update the overview, capabilities, or install instructions if the release adds/removes clients, changes requirements, or shifts beta/stable status.

### Unreleased Section Convention

Always keep an `[Unreleased]` section at the top of the changelog. When it's empty, include a placeholder to visually separate it from the latest release:

```markdown
## [Unreleased]

*No unreleased changes.*

## [x.y.z] - YYYY-MM-DD
```

Remove the placeholder line when adding the first unreleased entry.

### Docstring Hygiene

All public API docstrings — new and existing — must use **Google style**. `mkdocs.yml` is configured with `docstring_style: google`; NumPy-style docstrings will render incorrectly in the docs site.

Google-style format:

```python
def slice_time(self, dataset: FlexDataset, start_sec: float, end_sec: float) -> DatasetMetadata:
    """Slice a dataset in time.

    Args:
        dataset: Dataset to slice (metadata, ID string, or Path).
        start_sec: Start time in seconds.
        end_sec: End time in seconds.

    Returns:
        Metadata for the new (sliced) dataset.

    Raises:
        RadiensError: If the server rejects the operation.
    """
```

Key rules:

- `Args:` entries: `param: Description.` (4-space indent, colon after name)
- `Returns:` body: describe the value, not the type (the type is in the signature)
- `Raises:` entries: `ExceptionType: When this is raised.`
- Omit empty sections (`Raises:` is optional when there are none to document)
- If you encounter NumPy-style docstrings (param name on its own line, description indented below), convert them to Google-style.

### Docs Preview

```bash
uv run mkdocs serve
# Navigate to http://127.0.0.1:8000/changelog/ to verify
```

## Quick Reference

```bash
# Setup
uv sync

# Run all checks and fixes (Ruff + Mypy + Pytest)
./scripts/check-fix.sh

# Type check (most important)
uv run mypy --strict src/ tests/

# Lint
uv run ruff check src/ tests/

# Test
uv run pytest tests/unit/ -v

# Auto-fix and format (run after every significant change)
uv run ruff check --fix src/ tests/
uv run ruff format src/ tests/

# Build
uv build

# Publish
uv run twine upload --repository radiens-core dist/*
```

## Client APIs

```python
from radiens_core import AllegoClient, VidereClient, CurateClient

# Real-time acquisition — auto-discovers servers, falls back to hardcoded ports
with AllegoClient() as client:
    params = client.get_stim_params()

# Offline analysis — auto-discovers radiensserver (must be running)
with VidereClient() as client:
    meta = client.link_data_file("/path/to/recording.xdat")
    sigs = client.get_signals(meta, time_range=[0, 1], signals=[0, 1, 2])

# Data curation — auto-discovers radiensserver (must be running)
with CurateClient() as client:
    meta = client.link_data_file("/path/to/recording.xdat")
    new_meta = client.slice_time(meta, 0, 10, "sliced.xdat")
```

All clients take **zero constructor arguments** and use service discovery. `AllegoClient` falls back to well-known ports (50051–50054) if no `radiensserver` is running. `VidereClient` and `CurateClient` require a running `radiensserver`.

All clients return domain models (never proto types).
