From d12302630ad9f91ef0096f340274d42950df460e Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 16 Mar 2026 10:48:29 +0000 Subject: [PATCH 1/5] Add cross-language symmetry tests and CI workflow Add makeArtifact and readArtifact symmetry tests (mirroring DID-matlab's tests/+did/+symmetry/) that verify DID databases created in Python can be read by MATLAB and vice versa. Includes: - tests/symmetry/make_artifacts/ - generates database + JSON summary artifacts - tests/symmetry/read_artifacts/ - validates artifacts from either language - src/did/util/ - database_summary and compare_database_summary utilities - .github/workflows/symmetry.yml - 3-stage CI: MATLAB make -> Python make+read -> MATLAB read - Updated README with instructions for running test groups separately - Added pytest markers (symmetry, make_artifacts, read_artifacts) https://claude.ai/code/session_01JeFfZ9DvpQws6LUCk5a8E6 --- .github/workflows/symmetry.yml | 132 ++++++++++++++++++ README.md | 44 +++++- pyproject.toml | 8 ++ src/did/util/__init__.py | 2 + src/did/util/compare_database_summary.py | 122 ++++++++++++++++ src/did/util/database_summary.py | 77 ++++++++++ tests/symmetry/__init__.py | 0 tests/symmetry/conftest.py | 29 ++++ tests/symmetry/make_artifacts/__init__.py | 0 .../make_artifacts/database/__init__.py | 0 .../database/test_build_database.py | 115 +++++++++++++++ tests/symmetry/read_artifacts/__init__.py | 0 .../read_artifacts/database/__init__.py | 0 .../database/test_build_database.py | 109 +++++++++++++++ 14 files changed, 635 insertions(+), 3 deletions(-) create mode 100644 .github/workflows/symmetry.yml create mode 100644 src/did/util/__init__.py create mode 100644 src/did/util/compare_database_summary.py create mode 100644 src/did/util/database_summary.py create mode 100644 tests/symmetry/__init__.py create mode 100644 tests/symmetry/conftest.py create mode 100644 tests/symmetry/make_artifacts/__init__.py create mode 100644 tests/symmetry/make_artifacts/database/__init__.py create mode 100644 tests/symmetry/make_artifacts/database/test_build_database.py create mode 100644 tests/symmetry/read_artifacts/__init__.py create mode 100644 tests/symmetry/read_artifacts/database/__init__.py create mode 100644 tests/symmetry/read_artifacts/database/test_build_database.py diff --git a/.github/workflows/symmetry.yml b/.github/workflows/symmetry.yml new file mode 100644 index 0000000..e009196 --- /dev/null +++ b/.github/workflows/symmetry.yml @@ -0,0 +1,132 @@ +name: Cross-Language Symmetry Tests + +on: + push: + branches: [main] + pull_request: + branches: [main] + workflow_dispatch: + +jobs: + # Step 1: Run MATLAB makeArtifact tests to generate artifacts + matlab-make-artifacts: + runs-on: ubuntu-latest + steps: + - name: Check out DID-matlab + uses: actions/checkout@v4 + with: + repository: VH-Lab/DID-matlab + path: DID-matlab + + - name: Set up MATLAB + uses: matlab-actions/setup-matlab@v2 + with: + release: latest + + - name: Run MATLAB makeArtifact tests + uses: matlab-actions/run-tests@v2 + with: + source-folder: DID-matlab/src/did + select-by-folder: DID-matlab/tests + select-by-tag: "" + test-results-junit: matlab-make-results.xml + start-script: | + addpath(genpath('DID-matlab/src/did')); + addpath(genpath('DID-matlab/tests')); + addpath(genpath('DID-matlab/tools')); + + - name: Run MATLAB makeArtifact symmetry tests specifically + uses: matlab-actions/run-command@v2 + with: + command: | + addpath(genpath('DID-matlab/src/did')); + addpath(genpath('DID-matlab/tests')); + addpath(genpath('DID-matlab/tools')); + results = runtests('did.symmetry.makeArtifacts', 'IncludeSubpackages', true); + assertSuccess(results); + + - name: Upload MATLAB artifacts + uses: actions/upload-artifact@v4 + with: + name: matlab-artifacts + path: ${{ runner.temp }}/DID/symmetryTest/matlabArtifacts/ + retention-days: 1 + + # Step 2: Run Python makeArtifact tests, then readArtifact tests (which read both Python + MATLAB artifacts) + python-symmetry: + runs-on: ubuntu-latest + needs: matlab-make-artifacts + strategy: + fail-fast: false + matrix: + python-version: ["3.12"] + steps: + - name: Check out DID-python + uses: actions/checkout@v4 + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -e ".[dev]" + + - name: Download MATLAB artifacts + uses: actions/download-artifact@v4 + with: + name: matlab-artifacts + path: ${{ runner.temp }}/DID/symmetryTest/matlabArtifacts/ + + - name: Run Python makeArtifact tests + run: pytest -m make_artifacts -v + + - name: Run Python readArtifact tests + run: pytest -m read_artifacts -v + + - name: Upload Python artifacts + uses: actions/upload-artifact@v4 + with: + name: python-artifacts + path: ${{ runner.temp }}/DID/symmetryTest/pythonArtifacts/ + retention-days: 1 + + # Step 3: Run MATLAB readArtifact tests (reads Python-created artifacts) + matlab-read-artifacts: + runs-on: ubuntu-latest + needs: python-symmetry + steps: + - name: Check out DID-matlab + uses: actions/checkout@v4 + with: + repository: VH-Lab/DID-matlab + path: DID-matlab + + - name: Set up MATLAB + uses: matlab-actions/setup-matlab@v2 + with: + release: latest + + - name: Download Python artifacts + uses: actions/download-artifact@v4 + with: + name: python-artifacts + path: ${{ runner.temp }}/DID/symmetryTest/pythonArtifacts/ + + - name: Download MATLAB artifacts + uses: actions/download-artifact@v4 + with: + name: matlab-artifacts + path: ${{ runner.temp }}/DID/symmetryTest/matlabArtifacts/ + + - name: Run MATLAB readArtifact symmetry tests + uses: matlab-actions/run-command@v2 + with: + command: | + addpath(genpath('DID-matlab/src/did')); + addpath(genpath('DID-matlab/tests')); + addpath(genpath('DID-matlab/tools')); + results = runtests('did.symmetry.readArtifacts', 'IncludeSubpackages', true); + assertSuccess(results); diff --git a/README.md b/README.md index 15cc414..080fff9 100644 --- a/README.md +++ b/README.md @@ -43,17 +43,55 @@ The `did` library provides a framework for managing and querying data that is or You can run the tests using either `pytest` (if you installed the development dependencies) or the standard `unittest` module. -**Using pytest (Recommended for development):** +**Run all tests (unit + symmetry):** ```bash pytest ``` -**Using unittest (Standard):** +**Run only the unit tests (excluding symmetry tests):** +```bash +pytest tests/ --ignore=tests/symmetry +``` + +**Run only the symmetry tests:** +```bash +pytest -m symmetry +``` + +**Run only the makeArtifact symmetry tests** (generate cross-language artifacts): +```bash +pytest -m make_artifacts +``` + +**Run only the readArtifact symmetry tests** (validate artifacts from Python and/or MATLAB): +```bash +pytest -m read_artifacts +``` + +**Using unittest (unit tests only):** ```bash python -m unittest discover tests ``` -Both commands will discover and run all the tests in the `tests` directory. +#### Symmetry Tests + +The `tests/symmetry/` directory contains cross-language symmetry tests that verify +DID databases created in Python can be read by MATLAB and vice versa: + +* **`make_artifacts/`** — Creates a DID database with multiple branches and + documents, then writes the database file and JSON summary artifacts to a + well-known temporary directory + (`/DID/symmetryTest/pythonArtifacts/`). +* **`read_artifacts/`** — Reads artifacts produced by either the Python or + MATLAB test suite, re-summarizes the live database, and compares the + result against the saved summary. Tests are parameterized over + `matlabArtifacts` and `pythonArtifacts` and skip gracefully when + artifacts from a given source are not available. + +The CI workflow runs the full cross-language cycle: +1. MATLAB `makeArtifact` tests create artifacts +2. Python `makeArtifact` and `readArtifact` tests run (reading MATLAB artifacts) +3. MATLAB `readArtifact` tests run (reading Python artifacts) ## Documentation diff --git a/pyproject.toml b/pyproject.toml index 5ff0f36..dd178c1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -27,6 +27,14 @@ dev = [ "pytest", ] +[tool.pytest.ini_options] +testpaths = ["tests"] +markers = [ + "symmetry: cross-language symmetry tests (MATLAB <-> Python)", + "make_artifacts: tests that generate artifacts for symmetry testing", + "read_artifacts: tests that read and validate artifacts from another implementation", +] + [tool.setuptools.packages.find] where = ["src"] diff --git a/src/did/util/__init__.py b/src/did/util/__init__.py new file mode 100644 index 0000000..06e58ca --- /dev/null +++ b/src/did/util/__init__.py @@ -0,0 +1,2 @@ +from .database_summary import database_summary +from .compare_database_summary import compare_database_summary diff --git a/src/did/util/compare_database_summary.py b/src/did/util/compare_database_summary.py new file mode 100644 index 0000000..cdd2bfa --- /dev/null +++ b/src/did/util/compare_database_summary.py @@ -0,0 +1,122 @@ +"""Compare two database summaries and return a list of discrepancy messages. + +Mirrors MATLAB's did.util.compareDatabaseSummary for cross-language symmetry testing. +""" + + +def compare_database_summary(summary_a, summary_b): + """Compare two summary dicts and return a list of mismatch descriptions. + + Returns an empty list when the summaries are equivalent. + """ + report = [] + + branches_a = summary_a.get("branchNames", []) + branches_b = summary_b.get("branchNames", []) + + only_in_a = set(branches_a) - set(branches_b) + only_in_b = set(branches_b) - set(branches_a) + + for name in sorted(only_in_a): + report.append(f'Branch "{name}" exists only in summary A.') + for name in sorted(only_in_b): + report.append(f'Branch "{name}" exists only in summary B.') + + # Compare branch hierarchy + hier_a = summary_a.get("branchHierarchy", {}) + hier_b = summary_b.get("branchHierarchy", {}) + common_branches = sorted(set(branches_a) & set(branches_b)) + + for branch_name in common_branches: + if branch_name in hier_a and branch_name in hier_b: + parent_a = hier_a[branch_name].get("parent", "") + parent_b = hier_b[branch_name].get("parent", "") + if parent_a != parent_b: + report.append( + f'Branch "{branch_name}": parent mismatch ' + f'("{parent_a}" vs "{parent_b}").' + ) + + # Compare per-branch documents + br_a = summary_a.get("branches", {}) + br_b = summary_b.get("branches", {}) + + for branch_name in common_branches: + if branch_name not in br_a or branch_name not in br_b: + continue + + branch_a = br_a[branch_name] + branch_b = br_b[branch_name] + + if branch_a["docCount"] != branch_b["docCount"]: + report.append( + f'Branch "{branch_name}": doc count mismatch ' + f'({branch_a["docCount"]} vs {branch_b["docCount"]}).' + ) + + map_a = {d["id"]: d for d in branch_a.get("documents", [])} + map_b = {d["id"]: d for d in branch_b.get("documents", [])} + + missing_in_a = sorted(set(map_b) - set(map_a)) + missing_in_b = sorted(set(map_a) - set(map_b)) + + for doc_id in missing_in_a: + report.append( + f'Branch "{branch_name}": doc "{doc_id}" missing in summary A.' + ) + for doc_id in missing_in_b: + report.append( + f'Branch "{branch_name}": doc "{doc_id}" missing in summary B.' + ) + + for doc_id in sorted(set(map_a) & set(map_b)): + doc_a = map_a[doc_id] + doc_b = map_b[doc_id] + + if doc_a.get("className", "") != doc_b.get("className", ""): + report.append( + f'Branch "{branch_name}", doc "{doc_id}": class name mismatch ' + f'("{doc_a["className"]}" vs "{doc_b["className"]}").' + ) + + props_a = doc_a.get("properties", {}) + props_b = doc_b.get("properties", {}) + + for field in ("demoA", "demoB", "demoC"): + has_a = field in props_a + has_b = field in props_b + if has_a and has_b: + if props_a[field] != props_b[field]: + report.append( + f'Branch "{branch_name}", doc "{doc_id}": ' + f"{field} mismatch." + ) + elif has_a != has_b: + report.append( + f'Branch "{branch_name}", doc "{doc_id}": ' + f'field "{field}" present in one summary but not the other.' + ) + + # Compare depends_on + deps_a = props_a.get("depends_on", []) + deps_b = props_b.get("depends_on", []) + if isinstance(deps_a, dict): + deps_a = [deps_a] + if isinstance(deps_b, dict): + deps_b = [deps_b] + norm_a = [ + (d.get("name", ""), d.get("value", "")) + for d in deps_a + if isinstance(d, dict) + ] + norm_b = [ + (d.get("name", ""), d.get("value", "")) + for d in deps_b + if isinstance(d, dict) + ] + if norm_a != norm_b: + report.append( + f'Branch "{branch_name}", doc "{doc_id}": depends_on mismatch.' + ) + + return report diff --git a/src/did/util/database_summary.py b/src/did/util/database_summary.py new file mode 100644 index 0000000..ad609b0 --- /dev/null +++ b/src/did/util/database_summary.py @@ -0,0 +1,77 @@ +"""Produce a summary dict of a DID database and its branches. + +Mirrors MATLAB's did.util.databaseSummary for cross-language symmetry testing. +""" + + +def database_summary(db): + """Return a dict summarizing every branch and document in *db*. + + The returned dict contains: + - ``branchNames``: sorted list of all branch IDs + - ``branchHierarchy``: dict mapping each branch name to its parent + - ``branches``: dict keyed by branch name, each containing: + - ``branchName`` + - ``docCount`` + - ``documents``: list of dicts with ``id``, ``className``, ``properties`` + - ``dbFilename``: empty string (caller should fill in) + + Documents within each branch are sorted by ID for determinism. + """ + summary = {} + summary["dbFilename"] = "" + + branch_names = db.all_branch_ids() + if isinstance(branch_names, str): + branch_names = [branch_names] + branch_names = sorted(branch_names) + summary["branchNames"] = branch_names + + # Branch hierarchy + branch_hierarchy = {} + for branch_name in branch_names: + parent = db.get_branch_parent(branch_name) + if parent is None: + parent = "" + branch_hierarchy[branch_name] = { + "branchName": branch_name, + "parent": parent, + } + summary["branchHierarchy"] = branch_hierarchy + + # Per-branch document summaries + branches = {} + for branch_name in branch_names: + doc_ids = db.get_doc_ids(branch_name) + if isinstance(doc_ids, str): + doc_ids = [doc_ids] + if not doc_ids: + doc_ids = [] + doc_ids = sorted(doc_ids) + + doc_summaries = [] + for doc_id in doc_ids: + doc = db.get_docs(doc_id) + props = doc.document_properties + + class_name = "" + dc = props.get("document_class", {}) + if isinstance(dc, dict): + class_name = dc.get("class_name", "") + + doc_summaries.append( + { + "id": doc_id, + "className": class_name, + "properties": props, + } + ) + + branches[branch_name] = { + "branchName": branch_name, + "docCount": len(doc_ids), + "documents": doc_summaries, + } + summary["branches"] = branches + + return summary diff --git a/tests/symmetry/__init__.py b/tests/symmetry/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/symmetry/conftest.py b/tests/symmetry/conftest.py new file mode 100644 index 0000000..52bb6d6 --- /dev/null +++ b/tests/symmetry/conftest.py @@ -0,0 +1,29 @@ +"""Shared constants and fixtures for DID symmetry tests.""" + +import os +import tempfile + +import pytest + +SYMMETRY_BASE = os.path.join(tempfile.gettempdir(), "DID", "symmetryTest") +PYTHON_ARTIFACTS = os.path.join(SYMMETRY_BASE, "pythonArtifacts") +MATLAB_ARTIFACTS = os.path.join(SYMMETRY_BASE, "matlabArtifacts") +SOURCE_TYPES = ["matlabArtifacts", "pythonArtifacts"] + + +def pytest_collection_modifyitems(config, items): + """Auto-apply symmetry markers based on test file path.""" + for item in items: + path = str(item.fspath) + if "symmetry" in path: + item.add_marker(pytest.mark.symmetry) + if os.path.join("make_artifacts", "") in path: + item.add_marker(pytest.mark.make_artifacts) + if os.path.join("read_artifacts", "") in path: + item.add_marker(pytest.mark.read_artifacts) + + +@pytest.fixture(params=SOURCE_TYPES) +def source_type(request): + """Parameterized fixture that yields each artifact source type.""" + return request.param diff --git a/tests/symmetry/make_artifacts/__init__.py b/tests/symmetry/make_artifacts/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/symmetry/make_artifacts/database/__init__.py b/tests/symmetry/make_artifacts/database/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/symmetry/make_artifacts/database/test_build_database.py b/tests/symmetry/make_artifacts/database/test_build_database.py new file mode 100644 index 0000000..87e3efb --- /dev/null +++ b/tests/symmetry/make_artifacts/database/test_build_database.py @@ -0,0 +1,115 @@ +"""makeArtifact symmetry test: build a DID database and export summary artifacts. + +Mirrors DID-matlab's tests/+did/+symmetry/+makeArtifacts/+database/buildDatabase.m. + +Artifacts are written to: + /DID/symmetryTest/pythonArtifacts/database/buildDatabase/testBuildDatabaseArtifacts/ + +The readArtifact counterpart (and the MATLAB readArtifact test) will later open +these artifacts and compare them against a live database summary. +""" + +import json +import os +import random +import shutil + +import numpy as np +import pytest + +from did.document import Document +from did.implementations.sqlitedb import SQLiteDB +from did.util import compare_database_summary, database_summary +from tests.helpers import make_doc_tree +from tests.symmetry.conftest import PYTHON_ARTIFACTS + + +DB_FILENAME = "symmetry_test.sqlite" + +ARTIFACT_DIR = os.path.join( + PYTHON_ARTIFACTS, + "database", + "buildDatabase", + "testBuildDatabaseArtifacts", +) + + +class TestBuildDatabase: + """Generate DID database artifacts for cross-language symmetry testing.""" + + def test_build_database_artifacts(self): + # Use fixed seeds for reproducibility + random.seed(0) + np.random.seed(0) + + # Clean previous artifacts + if os.path.isdir(ARTIFACT_DIR): + shutil.rmtree(ARTIFACT_DIR) + os.makedirs(ARTIFACT_DIR, exist_ok=True) + + # Step 1: Create the database + db_path = os.path.join(ARTIFACT_DIR, DB_FILENAME) + db = SQLiteDB(db_path) + + # Step 2: Create 3 branches in a simple hierarchy: + # branch_main + # +-- branch_dev + # +-- branch_feature + branch_names = ["branch_main", "branch_dev", "branch_feature"] + + # Root branch with documents + db.add_branch(branch_names[0]) + _, _, root_docs = make_doc_tree([3, 3, 3]) + db.add_docs(root_docs, branch_id=branch_names[0]) + + # branch_dev as child of branch_main + db.set_branch(branch_names[0]) + db.add_branch(branch_names[1]) + _, _, dev_docs = make_doc_tree([2, 2, 2]) + db.add_docs(dev_docs, branch_id=branch_names[1]) + + # branch_feature as child of branch_main + db.set_branch(branch_names[0]) + db.add_branch(branch_names[2]) + _, _, feature_docs = make_doc_tree([2, 1, 2]) + db.add_docs(feature_docs, branch_id=branch_names[2]) + + # Step 3: Generate summary + summary = database_summary(db) + summary["dbFilename"] = DB_FILENAME + + # Step 4: Write per-branch JSON files + json_branches_dir = os.path.join(ARTIFACT_DIR, "jsonBranches") + os.makedirs(json_branches_dir, exist_ok=True) + + for branch_name in branch_names: + branch_data = summary["branches"][branch_name] + branch_json_path = os.path.join( + json_branches_dir, f"branch_{branch_name}.json" + ) + with open(branch_json_path, "w") as f: + json.dump(branch_data, f, indent=2) + + # Write the full summary JSON + summary_path = os.path.join(ARTIFACT_DIR, "summary.json") + with open(summary_path, "w") as f: + json.dump(summary, f, indent=2) + + # Step 5: Verify artifacts were created + assert os.path.isfile(db_path), "Database file was not created." + assert os.path.isfile(summary_path), "summary.json was not created." + for branch_name in branch_names: + branch_file = os.path.join( + json_branches_dir, f"branch_{branch_name}.json" + ) + assert os.path.isfile(branch_file), ( + f"Branch JSON file missing for {branch_name}" + ) + + # Step 6: Self-check -- re-summarize and compare + summary_check = database_summary(db) + summary_check["dbFilename"] = DB_FILENAME + self_report = compare_database_summary(summary, summary_check) + assert self_report == [], f"Self-check failed: {'; '.join(self_report)}" + + db._close_db() diff --git a/tests/symmetry/read_artifacts/__init__.py b/tests/symmetry/read_artifacts/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/symmetry/read_artifacts/database/__init__.py b/tests/symmetry/read_artifacts/database/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/symmetry/read_artifacts/database/test_build_database.py b/tests/symmetry/read_artifacts/database/test_build_database.py new file mode 100644 index 0000000..95d2935 --- /dev/null +++ b/tests/symmetry/read_artifacts/database/test_build_database.py @@ -0,0 +1,109 @@ +"""readArtifact symmetry test: read and validate DID database artifacts. + +Mirrors DID-matlab's tests/+did/+symmetry/+readArtifacts/+database/buildDatabase.m. + +This test is parameterized over SOURCE_TYPES (matlabArtifacts, pythonArtifacts). +It reads artifacts produced by either the Python makeArtifact test above or by +the MATLAB makeArtifact test, then validates them against a live database summary. +""" + +import json +import os + +import pytest + +from did.implementations.sqlitedb import SQLiteDB +from did.util import compare_database_summary, database_summary +from tests.symmetry.conftest import SYMMETRY_BASE + + +class TestReadBuildDatabase: + """Read and validate DID database artifacts for cross-language symmetry testing.""" + + def test_build_database_artifacts(self, source_type): + artifact_dir = os.path.join( + SYMMETRY_BASE, + source_type, + "database", + "buildDatabase", + "testBuildDatabaseArtifacts", + ) + + if not os.path.isdir(artifact_dir): + pytest.skip( + f"Artifact directory from {source_type} does not exist: {artifact_dir}" + ) + + # Step 1: Load the saved summary + summary_file = os.path.join(artifact_dir, "summary.json") + if not os.path.isfile(summary_file): + pytest.skip(f"summary.json not found in {source_type} artifact directory.") + + with open(summary_file, "r") as f: + saved_summary = json.load(f) + + assert "branchNames" in saved_summary, "summary.json missing branchNames field." + assert "dbFilename" in saved_summary, "summary.json missing dbFilename field." + + # Step 2: Open the DID database and produce a live summary + db_path = os.path.join(artifact_dir, saved_summary["dbFilename"]) + if not os.path.isfile(db_path): + pytest.skip(f"Database file not found: {db_path}") + + db = SQLiteDB(db_path) + live_summary = database_summary(db) + + # Step 3: Compare the saved summary against the live database summary + report = compare_database_summary(saved_summary, live_summary) + assert report == [], ( + f"Database summary mismatch for {source_type}: {'; '.join(report)}" + ) + + # Step 4: Also verify per-branch JSON files match the live database + branch_names = saved_summary["branchNames"] + if isinstance(branch_names, str): + branch_names = [branch_names] + + json_branches_dir = os.path.join(artifact_dir, "jsonBranches") + if not os.path.isdir(json_branches_dir): + pytest.skip(f"jsonBranches directory not found in {source_type}") + + for branch_name in branch_names: + branch_json_file = os.path.join( + json_branches_dir, f"branch_{branch_name}.json" + ) + if not os.path.isfile(branch_json_file): + pytest.skip( + f"Branch JSON file missing for {branch_name} in {source_type}" + ) + + with open(branch_json_file, "r") as f: + saved_branch = json.load(f) + + # Verify document count matches the live database + actual_doc_ids = db.get_doc_ids(branch_name) + assert len(actual_doc_ids) == saved_branch["docCount"], ( + f"Document count mismatch in branch {branch_name} from {source_type}" + ) + + # Verify each saved document exists in the live database + saved_docs = saved_branch.get("documents", []) + for saved_doc in saved_docs: + expected_id = saved_doc["id"] + doc = db.get_docs(expected_id, OnMissing="ignore") + assert doc is not None, ( + f"Document {expected_id} from {source_type} " + f"not found in database branch {branch_name}" + ) + + if doc is not None: + actual_props = doc.document_properties + actual_class = actual_props.get("document_class", {}).get( + "class_name", "" + ) + assert actual_class == saved_doc["className"], ( + f"Class name mismatch for doc {expected_id} " + f"in branch {branch_name} from {source_type}" + ) + + db._close_db() From 4f1fe6c86a7b61c1b691fc889aef9aca63f5c23f Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 16 Mar 2026 11:03:53 +0000 Subject: [PATCH 2/5] Fix MATLAB CI setup: install MatBox and dependencies (mksqlite) The MATLAB jobs need MatBox (ehennestad/matbox-actions/install-matbox@v1) to install project requirements (mksqlite, vhlab-toolbox-matlab) before running tests. Also adds Statistics_and_Machine_Learning_Toolbox product, enables MATLAB caching, and uses the proper TestSuite/TestRunner pattern matching DID-matlab's own test-symmetry.yml workflow. https://claude.ai/code/session_01JeFfZ9DvpQws6LUCk5a8E6 --- .github/workflows/symmetry.yml | 72 +++++++++++++++++++++++----------- 1 file changed, 50 insertions(+), 22 deletions(-) diff --git a/.github/workflows/symmetry.yml b/.github/workflows/symmetry.yml index e009196..d6a320f 100644 --- a/.github/workflows/symmetry.yml +++ b/.github/workflows/symmetry.yml @@ -22,28 +22,34 @@ jobs: uses: matlab-actions/setup-matlab@v2 with: release: latest + cache: true + products: Statistics_and_Machine_Learning_Toolbox - - name: Run MATLAB makeArtifact tests - uses: matlab-actions/run-tests@v2 + - name: Install MatBox + uses: ehennestad/matbox-actions/install-matbox@v1 + + - name: Install dependencies (mksqlite etc.) + uses: matlab-actions/run-command@v2 with: - source-folder: DID-matlab/src/did - select-by-folder: DID-matlab/tests - select-by-tag: "" - test-results-junit: matlab-make-results.xml - start-script: | - addpath(genpath('DID-matlab/src/did')); - addpath(genpath('DID-matlab/tests')); - addpath(genpath('DID-matlab/tools')); - - - name: Run MATLAB makeArtifact symmetry tests specifically + command: | + addpath(genpath("DID-matlab/src")); + addpath(genpath("DID-matlab/tools")); + matbox.installRequirements(fullfile(pwd, "DID-matlab")); + + - name: Run MATLAB makeArtifact symmetry tests uses: matlab-actions/run-command@v2 with: command: | - addpath(genpath('DID-matlab/src/did')); - addpath(genpath('DID-matlab/tests')); - addpath(genpath('DID-matlab/tools')); - results = runtests('did.symmetry.makeArtifacts', 'IncludeSubpackages', true); - assertSuccess(results); + addpath(genpath("DID-matlab/src")); + addpath(genpath("DID-matlab/tests")); + addpath(genpath("DID-matlab/tools")); + import matlab.unittest.TestRunner; + import matlab.unittest.TestSuite; + runner = TestRunner.withTextOutput; + makeSuite = TestSuite.fromPackage("did.symmetry.makeArtifacts", "IncludingSubpackages", true); + makeResults = runner.run(makeSuite); + disp(table(makeResults)); + assert(all([makeResults.Passed]), "makeArtifacts tests failed"); - name: Upload MATLAB artifacts uses: actions/upload-artifact@v4 @@ -108,6 +114,19 @@ jobs: uses: matlab-actions/setup-matlab@v2 with: release: latest + cache: true + products: Statistics_and_Machine_Learning_Toolbox + + - name: Install MatBox + uses: ehennestad/matbox-actions/install-matbox@v1 + + - name: Install dependencies (mksqlite etc.) + uses: matlab-actions/run-command@v2 + with: + command: | + addpath(genpath("DID-matlab/src")); + addpath(genpath("DID-matlab/tools")); + matbox.installRequirements(fullfile(pwd, "DID-matlab")); - name: Download Python artifacts uses: actions/download-artifact@v4 @@ -125,8 +144,17 @@ jobs: uses: matlab-actions/run-command@v2 with: command: | - addpath(genpath('DID-matlab/src/did')); - addpath(genpath('DID-matlab/tests')); - addpath(genpath('DID-matlab/tools')); - results = runtests('did.symmetry.readArtifacts', 'IncludeSubpackages', true); - assertSuccess(results); + addpath(genpath("DID-matlab/src")); + addpath(genpath("DID-matlab/tests")); + addpath(genpath("DID-matlab/tools")); + import matlab.unittest.TestRunner; + import matlab.unittest.TestSuite; + runner = TestRunner.withTextOutput; + readSuite = TestSuite.fromPackage("did.symmetry.readArtifacts", "IncludingSubpackages", true); + readResults = runner.run(readSuite); + disp(table(readResults)); + nFailed = sum([readResults.Failed]); + nPassed = sum([readResults.Passed]); + nSkipped = sum([readResults.Incomplete]); + fprintf("Results: %d passed, %d failed, %d skipped\n", nPassed, nFailed, nSkipped); + assert(nFailed == 0, "readArtifacts tests failed"); From a40be8053ffbe1946b5735b402bd7f962f1d4c74 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 16 Mar 2026 11:29:20 +0000 Subject: [PATCH 3/5] Fix lint errors and combine CI into single job - Fix black formatting and ruff lint errors (unused imports, re-export aliases) - Combine all three CI stages (MATLAB make, Python make+read, MATLAB read) into a single job so artifacts in the runner's temp directory are shared across all steps https://claude.ai/code/session_01JeFfZ9DvpQws6LUCk5a8E6 --- .github/workflows/symmetry.yml | 115 ++++-------------- src/did/util/__init__.py | 6 +- .../database/test_build_database.py | 13 +- .../database/test_build_database.py | 12 +- 4 files changed, 39 insertions(+), 107 deletions(-) diff --git a/.github/workflows/symmetry.yml b/.github/workflows/symmetry.yml index d6a320f..ce2dba1 100644 --- a/.github/workflows/symmetry.yml +++ b/.github/workflows/symmetry.yml @@ -8,16 +8,20 @@ on: workflow_dispatch: jobs: - # Step 1: Run MATLAB makeArtifact tests to generate artifacts - matlab-make-artifacts: + symmetry: + name: MATLAB <-> Python symmetry tests runs-on: ubuntu-latest steps: + - name: Check out DID-python + uses: actions/checkout@v4 + - name: Check out DID-matlab uses: actions/checkout@v4 with: repository: VH-Lab/DID-matlab path: DID-matlab + # --- MATLAB setup --- - name: Set up MATLAB uses: matlab-actions/setup-matlab@v2 with: @@ -28,7 +32,7 @@ jobs: - name: Install MatBox uses: ehennestad/matbox-actions/install-matbox@v1 - - name: Install dependencies (mksqlite etc.) + - name: Install MATLAB dependencies (mksqlite etc.) uses: matlab-actions/run-command@v2 with: command: | @@ -36,7 +40,19 @@ jobs: addpath(genpath("DID-matlab/tools")); matbox.installRequirements(fullfile(pwd, "DID-matlab")); - - name: Run MATLAB makeArtifact symmetry tests + # --- Python setup --- + - name: Set up Python 3.12 + uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Install Python dependencies + run: | + python -m pip install --upgrade pip + pip install -e ".[dev]" + + # --- Step 1: MATLAB makeArtifacts --- + - name: "Step 1: MATLAB makeArtifact tests" uses: matlab-actions/run-command@v2 with: command: | @@ -51,96 +67,15 @@ jobs: disp(table(makeResults)); assert(all([makeResults.Passed]), "makeArtifacts tests failed"); - - name: Upload MATLAB artifacts - uses: actions/upload-artifact@v4 - with: - name: matlab-artifacts - path: ${{ runner.temp }}/DID/symmetryTest/matlabArtifacts/ - retention-days: 1 - - # Step 2: Run Python makeArtifact tests, then readArtifact tests (which read both Python + MATLAB artifacts) - python-symmetry: - runs-on: ubuntu-latest - needs: matlab-make-artifacts - strategy: - fail-fast: false - matrix: - python-version: ["3.12"] - steps: - - name: Check out DID-python - uses: actions/checkout@v4 - - - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v5 - with: - python-version: ${{ matrix.python-version }} - - - name: Install dependencies - run: | - python -m pip install --upgrade pip - pip install -e ".[dev]" - - - name: Download MATLAB artifacts - uses: actions/download-artifact@v4 - with: - name: matlab-artifacts - path: ${{ runner.temp }}/DID/symmetryTest/matlabArtifacts/ - - - name: Run Python makeArtifact tests + # --- Step 2: Python makeArtifacts + readArtifacts --- + - name: "Step 2: Python makeArtifact tests" run: pytest -m make_artifacts -v - - name: Run Python readArtifact tests + - name: "Step 2: Python readArtifact tests" run: pytest -m read_artifacts -v - - name: Upload Python artifacts - uses: actions/upload-artifact@v4 - with: - name: python-artifacts - path: ${{ runner.temp }}/DID/symmetryTest/pythonArtifacts/ - retention-days: 1 - - # Step 3: Run MATLAB readArtifact tests (reads Python-created artifacts) - matlab-read-artifacts: - runs-on: ubuntu-latest - needs: python-symmetry - steps: - - name: Check out DID-matlab - uses: actions/checkout@v4 - with: - repository: VH-Lab/DID-matlab - path: DID-matlab - - - name: Set up MATLAB - uses: matlab-actions/setup-matlab@v2 - with: - release: latest - cache: true - products: Statistics_and_Machine_Learning_Toolbox - - - name: Install MatBox - uses: ehennestad/matbox-actions/install-matbox@v1 - - - name: Install dependencies (mksqlite etc.) - uses: matlab-actions/run-command@v2 - with: - command: | - addpath(genpath("DID-matlab/src")); - addpath(genpath("DID-matlab/tools")); - matbox.installRequirements(fullfile(pwd, "DID-matlab")); - - - name: Download Python artifacts - uses: actions/download-artifact@v4 - with: - name: python-artifacts - path: ${{ runner.temp }}/DID/symmetryTest/pythonArtifacts/ - - - name: Download MATLAB artifacts - uses: actions/download-artifact@v4 - with: - name: matlab-artifacts - path: ${{ runner.temp }}/DID/symmetryTest/matlabArtifacts/ - - - name: Run MATLAB readArtifact symmetry tests + # --- Step 3: MATLAB readArtifacts --- + - name: "Step 3: MATLAB readArtifact tests" uses: matlab-actions/run-command@v2 with: command: | diff --git a/src/did/util/__init__.py b/src/did/util/__init__.py index 06e58ca..a875053 100644 --- a/src/did/util/__init__.py +++ b/src/did/util/__init__.py @@ -1,2 +1,4 @@ -from .database_summary import database_summary -from .compare_database_summary import compare_database_summary +from .database_summary import database_summary as database_summary +from .compare_database_summary import ( + compare_database_summary as compare_database_summary, +) diff --git a/tests/symmetry/make_artifacts/database/test_build_database.py b/tests/symmetry/make_artifacts/database/test_build_database.py index 87e3efb..3161ce8 100644 --- a/tests/symmetry/make_artifacts/database/test_build_database.py +++ b/tests/symmetry/make_artifacts/database/test_build_database.py @@ -15,15 +15,12 @@ import shutil import numpy as np -import pytest -from did.document import Document from did.implementations.sqlitedb import SQLiteDB from did.util import compare_database_summary, database_summary from tests.helpers import make_doc_tree from tests.symmetry.conftest import PYTHON_ARTIFACTS - DB_FILENAME = "symmetry_test.sqlite" ARTIFACT_DIR = os.path.join( @@ -99,12 +96,10 @@ def test_build_database_artifacts(self): assert os.path.isfile(db_path), "Database file was not created." assert os.path.isfile(summary_path), "summary.json was not created." for branch_name in branch_names: - branch_file = os.path.join( - json_branches_dir, f"branch_{branch_name}.json" - ) - assert os.path.isfile(branch_file), ( - f"Branch JSON file missing for {branch_name}" - ) + branch_file = os.path.join(json_branches_dir, f"branch_{branch_name}.json") + assert os.path.isfile( + branch_file + ), f"Branch JSON file missing for {branch_name}" # Step 6: Self-check -- re-summarize and compare summary_check = database_summary(db) diff --git a/tests/symmetry/read_artifacts/database/test_build_database.py b/tests/symmetry/read_artifacts/database/test_build_database.py index 95d2935..91a75df 100644 --- a/tests/symmetry/read_artifacts/database/test_build_database.py +++ b/tests/symmetry/read_artifacts/database/test_build_database.py @@ -55,9 +55,9 @@ def test_build_database_artifacts(self, source_type): # Step 3: Compare the saved summary against the live database summary report = compare_database_summary(saved_summary, live_summary) - assert report == [], ( - f"Database summary mismatch for {source_type}: {'; '.join(report)}" - ) + assert ( + report == [] + ), f"Database summary mismatch for {source_type}: {'; '.join(report)}" # Step 4: Also verify per-branch JSON files match the live database branch_names = saved_summary["branchNames"] @@ -82,9 +82,9 @@ def test_build_database_artifacts(self, source_type): # Verify document count matches the live database actual_doc_ids = db.get_doc_ids(branch_name) - assert len(actual_doc_ids) == saved_branch["docCount"], ( - f"Document count mismatch in branch {branch_name} from {source_type}" - ) + assert ( + len(actual_doc_ids) == saved_branch["docCount"] + ), f"Document count mismatch in branch {branch_name} from {source_type}" # Verify each saved document exists in the live database saved_docs = saved_branch.get("documents", []) From b78f7f607568758b61dd36edc88c82a5f9212db7 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 16 Mar 2026 11:48:49 +0000 Subject: [PATCH 4/5] Fix Document to handle dot-notation kwargs and align with MATLAB - Document.__init__ now properly handles dot-notation kwargs (e.g., 'demoA.value=42') by navigating/creating nested dicts, matching MATLAB's document_properties structure where demoA.value is a nested field - Fix pre-existing bug in field_search OR operation that required params to be dicts (they're lists) - Update compare_database_summary to compare .value subfield, matching MATLAB's compareDatabaseSummary behavior https://claude.ai/code/session_01JeFfZ9DvpQws6LUCk5a8E6 --- src/did/datastructures.py | 3 +-- src/did/document.py | 16 ++++++++++++---- src/did/util/compare_database_summary.py | 14 ++++++++++++-- 3 files changed, 25 insertions(+), 8 deletions(-) diff --git a/src/did/datastructures.py b/src/did/datastructures.py index 72697d4..b400d96 100644 --- a/src/did/datastructures.py +++ b/src/did/datastructures.py @@ -267,8 +267,7 @@ def field_search(a, search_struct): b = True break elif op_lower == "or": - if isinstance(param1, dict) and isinstance(param2, dict): - b = field_search(a, param1) or field_search(a, param2) + b = field_search(a, param1) or field_search(a, param2) elif op_lower == "depends_on": # param1 = dependency name, param2 = dependency value if "depends_on" in a: diff --git a/src/did/document.py b/src/did/document.py index ab66a63..4e3b1de 100644 --- a/src/did/document.py +++ b/src/did/document.py @@ -16,10 +16,18 @@ def __init__(self, document_type="base", **kwargs): self.document_properties["base"]["datestamp"] = str(datetime.utcnow()) for key, value in kwargs.items(): - # This is a simplified way to set properties. A full implementation - # would need to handle nested properties like 'base.name'. - if key in self.document_properties: - self.document_properties[key] = value + path = key.split(".") + if len(path) == 1: + if key in self.document_properties: + self.document_properties[key] = value + else: + d = self.document_properties + for p in path[:-1]: + existing = d.get(p) + if not isinstance(existing, dict): + d[p] = {} + d = d[p] + d[path[-1]] = value self._reset_file_info() diff --git a/src/did/util/compare_database_summary.py b/src/did/util/compare_database_summary.py index cdd2bfa..5cc5830 100644 --- a/src/did/util/compare_database_summary.py +++ b/src/did/util/compare_database_summary.py @@ -86,10 +86,20 @@ def compare_database_summary(summary_a, summary_b): has_a = field in props_a has_b = field in props_b if has_a and has_b: - if props_a[field] != props_b[field]: + val_a = ( + props_a[field].get("value") + if isinstance(props_a[field], dict) + else props_a[field] + ) + val_b = ( + props_b[field].get("value") + if isinstance(props_b[field], dict) + else props_b[field] + ) + if val_a != val_b: report.append( f'Branch "{branch_name}", doc "{doc_id}": ' - f"{field} mismatch." + f"{field}.value mismatch ({val_a} vs {val_b})." ) elif has_a != has_b: report.append( From ab862dc5613701bcd4ea44ef9533c988e581da19 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 16 Mar 2026 12:55:27 +0000 Subject: [PATCH 5/5] Add document_class field to match MATLAB document_properties format MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Transform flat classname/superclasses from schema files into nested document_class structure (class_name, superclasses, etc.) matching MATLAB's document definition format - Fix _normalize_loaded_props to re-wrap string superclasses back into lists after SQLite round-trip (was only handling dict→list case) https://claude.ai/code/session_01JeFfZ9DvpQws6LUCk5a8E6 --- src/did/document.py | 17 +++++++++++++++++ src/did/implementations/sqlitedb.py | 4 ++-- 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/src/did/document.py b/src/did/document.py index 4e3b1de..08fa340 100644 --- a/src/did/document.py +++ b/src/did/document.py @@ -103,6 +103,8 @@ def read_blank_definition(json_file_location_string): # Ensure the 'base' key exists if "base" not in data: data["base"] = {} + # Convert flat classname/superclasses to document_class format + data = Document._normalize_to_document_class(data) return data # Fallback for base @@ -121,6 +123,21 @@ def read_blank_definition(json_file_location_string): f"Could not find definition for {json_file_location_string}" ) + @staticmethod + def _normalize_to_document_class(data): + """Convert flat schema format to MATLAB-compatible document_class format.""" + if "document_class" in data: + return data + class_name = data.pop("classname", "") + superclasses = data.pop("superclasses", []) + data["document_class"] = { + "class_name": class_name, + "property_list_name": class_name, + "class_version": 1, + "superclasses": superclasses, + } + return data + def dependency_value(self, dependency_name, error_if_not_found=True): if "depends_on" in self.document_properties: for dep in self.document_properties["depends_on"]: diff --git a/src/did/implementations/sqlitedb.py b/src/did/implementations/sqlitedb.py index 962e6c2..2a20520 100644 --- a/src/did/implementations/sqlitedb.py +++ b/src/did/implementations/sqlitedb.py @@ -261,11 +261,11 @@ def _normalize_loaded_props(props): """ dc = props.get("document_class", {}) sc = dc.get("superclasses") - if isinstance(sc, dict): + if sc is not None and not isinstance(sc, list): dc["superclasses"] = [sc] dep = props.get("depends_on") - if isinstance(dep, dict): + if dep is not None and not isinstance(dep, list): props["depends_on"] = [dep] return props