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
139 changes: 139 additions & 0 deletions .github/workflows/test-symmetry.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
name: Test Cross-Language Symmetry

on:
push:
branches: [main]
pull_request:
branches: [main]
workflow_dispatch:

concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true

jobs:
symmetry:
name: MATLAB <-> Python symmetry tests
runs-on: ubuntu-latest

steps:
# -- Checkout both repos ----------------------------------------------
- name: Check out NDR-python
uses: actions/checkout@v4
with:
path: NDR-python

# Check out NDR-matlab at the same branch name as this workflow run
# (e.g. the PR's head branch or the branch being pushed to main).
# Developers are expected to keep matching branch names across the
# two repos when making cross-language changes, the same way the
# symmetry branches work. Falls back to main via the default so
# `main` pushes also work.
- name: Check out NDR-matlab (matching branch)
uses: actions/checkout@v4
with:
repository: VH-Lab/NDR-matlab
path: NDR-matlab
ref: ${{ github.head_ref || github.ref_name }}

# -- Runtime setup ----------------------------------------------------
- name: Start virtual display server
run: |
sudo apt-get install -y xvfb
Xvfb :99 &
echo "DISPLAY=:99" >> $GITHUB_ENV

- name: Set up MATLAB
uses: matlab-actions/setup-matlab@v2
with:
release: latest
cache: true
products: |
Signal_Processing_Toolbox
Statistics_and_Machine_Learning_Toolbox

- name: Install MatBox
uses: ehennestad/matbox-actions/install-matbox@v1

- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.12"

- name: Install NDR-python (with dev extras)
working-directory: NDR-python
run: |
python -m pip install --upgrade pip
pip install -e ".[dev]"

# -- Stage 1: MATLAB makeArtifacts ------------------------------------
- name: "Stage 1: MATLAB makeArtifacts"
uses: matlab-actions/run-command@v2
with:
command: |
% Add NDR-matlab repo root (for +ndr) and tools/tests
% (for +ndr/+symmetry). Note: MATLAB namespace (+foo)
% directories must NOT themselves appear on the path --
% only their parent directories should.
ndrRoot = fullfile(pwd, "NDR-matlab");
addpath(ndrRoot);
addpath(fullfile(ndrRoot, "tools", "tests"));

% Install MATLAB dependencies declared in tools/requirements.txt
% (e.g. vhlab-toolbox-matlab, which provides vlt.string.*).
matbox.installRequirements(fullfile(ndrRoot, "tools"));

% Skip ndr_Init -- it tries to mkdir filecache paths under
% userpath which are not writable on the CI runner.
% The symmetry tests only use ndr.fun.ndrpath() which is
% self-contained.

import matlab.unittest.TestSuite
import matlab.unittest.TestRunner

suite = TestSuite.fromPackage("ndr.symmetry.makeArtifacts", "IncludingSubpackages", true);
fprintf("\n=== MATLAB makeArtifacts: %d test(s) ===\n", numel(suite));
assert(~isempty(suite), "No MATLAB makeArtifacts tests found.")

runner = TestRunner.withTextOutput("Verbosity", "Detailed");
results = runner.run(suite);

fprintf("\n=== MATLAB makeArtifacts: %d passed, %d failed ===\n", ...
nnz([results.Passed]), nnz([results.Failed]));
assert(all(~[results.Failed]), "MATLAB makeArtifacts failed")

# -- Stage 2: Python readArtifacts (reads MATLAB artifacts) -----------
- name: "Stage 2: Python readArtifacts (reads MATLAB artifacts)"
working-directory: NDR-python
run: |
pytest tests/symmetry/read_artifacts/ -v --tb=short

# -- Stage 3: Python makeArtifacts ------------------------------------
- name: "Stage 3: Python makeArtifacts"
working-directory: NDR-python
run: |
pytest tests/symmetry/make_artifacts/ -v --tb=short

# -- Stage 4: MATLAB readArtifacts (reads Python artifacts) -----------
- name: "Stage 4: MATLAB readArtifacts (reads Python artifacts)"
uses: matlab-actions/run-command@v2
with:
command: |
ndrRoot = fullfile(pwd, "NDR-matlab");
addpath(ndrRoot);
addpath(fullfile(ndrRoot, "tools", "tests"));
matbox.installRequirements(fullfile(ndrRoot, "tools"));

import matlab.unittest.TestSuite
import matlab.unittest.TestRunner

suite = TestSuite.fromPackage("ndr.symmetry.readArtifacts", "IncludingSubpackages", true);
fprintf("\n=== MATLAB readArtifacts: %d test(s) ===\n", numel(suite));
assert(~isempty(suite), "No MATLAB readArtifacts tests found.")

runner = TestRunner.withTextOutput("Verbosity", "Detailed");
results = runner.run(suite);

fprintf("\n=== MATLAB readArtifacts: %d passed, %d failed ===\n", ...
nnz([results.Passed]), nnz([results.Failed]));
assert(all(~[results.Failed]), "MATLAB readArtifacts failed")
133 changes: 123 additions & 10 deletions docs/developer_notes/ndr_matlab_python_bridge.yaml
Original file line number Diff line number Diff line change
@@ -1,40 +1,153 @@
# The NDR Bridge Protocol: YAML Specification
#

# =============================================================================
# 1. File Purpose & Placement
# =============================================================================
# Name: ndr_matlab_python_bridge.yaml
# Location: One file per sub-package directory
# (e.g., src/ndr/reader/ndr_matlab_python_bridge.yaml).
# Role: Primary Contract. Defines how MATLAB names and types map to Python.
# Role: This is the Primary Contract. It defines how MATLAB names and types
# map to Python. If a function is not here, it does not officially
# exist in the Python port.

# =============================================================================
# 2. Standard Header
# =============================================================================
# Every bridge file must begin with this metadata to orient the agent:

project_metadata:
bridge_version: "1.1"
naming_policy: "Strict MATLAB Mirror"
indexing_policy: "Semantic Parity (1-based for user concepts, 0-based for internal data)"

# When porting a function:
# =============================================================================
# 3. The "Active Maintenance" Instruction
# =============================================================================
# When an agent or developer works on a function:
#
# 1. Check: Does the function/class exist in the YAML?
# 2. Add/Update: If missing or changed, update the YAML first.
# 3. Record Hash: git log -1 --format="%h" -- <path-to-matlab-file>
# 4. Notify: Tell the user what was added/changed.
# 2. Add/Update: If it is missing or the MATLAB signature has changed,
# update the YAML first.
# 3. Record Hash: Store the short git hash of the MATLAB .m file being
# ported in the `matlab_last_sync_hash` field. Obtain the hash with:
# git log -1 --format="%h" -- <path-to-matlab-file>
# 4. Notify: The agent MUST explicitly tell the user:
# "I have updated the ndr_matlab_python_bridge.yaml to include
# [Function Name]. Please review the interface contract."
#
# =============================================================================
# 3a. Detecting Upstream MATLAB Changes
# =============================================================================
# The `matlab_last_sync_hash` field enables efficient change detection:
#
# 1. For each entry, compare `matlab_last_sync_hash` against the current
# HEAD hash of the corresponding MATLAB .m file:
# git log -1 --format="%h" -- <matlab_path>
# 2. If the hashes differ, the MATLAB file has changed since the last sync
# and the Python port may need to be updated.
# 3. After re-syncing, update `matlab_last_sync_hash` to the new hash and
# record the sync date in `decision_log`.

# =============================================================================
# 4. Structure for Classes and Functions
# =============================================================================

# --- Example: Class ---
# - name: base
# - name: base # Exact MATLAB Name
# type: class
# matlab_path: "+ndr/+reader/base.m"
# python_path: "ndr/reader/base.py"
# matlab_last_sync_hash: "a4c9e07"
# python_class: "ndr_reader_base"
# matlab_last_sync_hash: "a4c9e07" # Git hash of base.m when last ported/synced
#
# properties:
# - name: MightHaveTimeGaps
# type_matlab: "logical"
# type_python: "bool"
# decision_log: "Mirroring property name exactly."
#
# methods:
# - name: readchannels_epochsamples
# input_arguments:
# - name: channeltype
# type_matlab: "char"
# type_python: "str"
# - name: channel
# type_matlab: "numeric"
# type_python: "int | list[int]"
# - name: epoch
# type_python: "str | int"
# - name: epochstreams
# type_matlab: "cell array of char"
# type_python: "list[str]"
# - name: epoch_select
# type_matlab: "numeric"
# type_python: "int"
# decision_log: "Semantic Parity: User provides 1-based ID."
# - name: s0
# type_python: "int"
# - name: s1
# type_python: "int"
# output_arguments:
# - name: data
# type_matlab: "double | int16 | int32"
# type_python: "numpy.ndarray"

# --- Example: Standalone Function ---
# - name: ndrpath
# type: function
# matlab_path: "+ndr/+fun/ndrpath.m"
# python_path: "ndr/fun/ndrpath.py"
# matlab_last_sync_hash: "8f3a2b1"
# input_arguments: []
# output_arguments:
# - name: p
# type_matlab: "char"
# type_python: "str"
# decision_log: "Synchronized with MATLAB main as of 2026-04-11."

# =============================================================================
# 5. Building a New Bridge YAML File
# =============================================================================
# When adding a brand-new sub-package to NDR-python (for example
# `src/ndr/newformat/`), create a fresh `ndr_matlab_python_bridge.yaml`
# next to the Python source using the following procedure:
#
# 1. Create the file at `src/ndr/<namespace>/ndr_matlab_python_bridge.yaml`.
# 2. Start with the Standard Header from Section 2 (copy verbatim).
# 3. For each MATLAB class in `+ndr/+<namespace>/`, add a `classes:` entry with:
# - `name` (MATLAB class name, e.g. `intan_rhd`)
# - `type: class`
# - `matlab_path` (e.g. `"+ndr/+reader/intan_rhd.m"`)
# - `python_path` (e.g. `"ndr/reader/intan_rhd.py"`)
# - `python_class` (derived using the Class Name Mirror Rule from
# `docs/developer_notes/PYTHON_PORTING_GUIDE.md`, e.g.
# `ndr_reader_intan__rhd`)
# - `inherits` (if applicable)
# - `matlab_last_sync_hash` (from `git log -1 --format="%h" -- <matlab_path>`)
# - `properties:` and `methods:` blocks with `input_arguments` /
# `output_arguments`, each argument carrying `name`, `type_matlab`, and
# `type_python`.
# 4. For each standalone MATLAB function, add an entry under `functions:` with
# the same fields (minus `properties` and `methods`).
# 5. Run `python -c "import yaml, pathlib; yaml.safe_load(pathlib.Path('src/ndr/<namespace>/ndr_matlab_python_bridge.yaml').read_text())"`
# to validate the YAML parses.
# 6. Notify the user with a one-line summary of the new contract so they can
# review it before any Python implementation is written.
#
# Refer to `src/ndr/reader/ndr_matlab_python_bridge.yaml` for a concrete,
# non-trivial example covering both `classes:` with inherited methods and
# `static_methods:` blocks.

# =============================================================================
# 6. Summary of Field Rules
# =============================================================================
# Field | Rule
# ---------------------|----------------------------------------------------------
# name | Strict Match. Case-sensitive match to the MATLAB .m file.
# matlab_last_sync_hash| The short git hash of the MATLAB .m file at the time it
# | was last examined/ported. Used to detect upstream changes.
# input_arguments | Used to generate Pydantic @validate_call.
# output_arguments | Defines the order of the Return Tuple.
# type_python | Use Python 3.10+ syntax (e.g., str | int).
# decision_log | Mandatory for any divergence. Should include the sync
# | date (e.g., "Synchronized with MATLAB main as of
# | 2026-04-11.").
Loading
Loading