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

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

jobs:
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:
release: latest
cache: true
products: Statistics_and_Machine_Learning_Toolbox

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

- name: Install MATLAB 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"));

# --- 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: |
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");

# --- Step 2: Python makeArtifacts + readArtifacts ---
- name: "Step 2: Python makeArtifact tests"
run: pytest -m make_artifacts -v

- name: "Step 2: Python readArtifact tests"
run: pytest -m read_artifacts -v

# --- Step 3: MATLAB readArtifacts ---
- name: "Step 3: MATLAB readArtifact tests"
uses: matlab-actions/run-command@v2
with:
command: |
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");
44 changes: 41 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
(`<tempdir>/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

Expand Down
8 changes: 8 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"]

Expand Down
3 changes: 1 addition & 2 deletions src/did/datastructures.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
33 changes: 29 additions & 4 deletions src/did/document.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand Down Expand Up @@ -95,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
Expand All @@ -113,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"]:
Expand Down
4 changes: 2 additions & 2 deletions src/did/implementations/sqlitedb.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions src/did/util/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
from .database_summary import database_summary as database_summary
from .compare_database_summary import (
compare_database_summary as compare_database_summary,
)
132 changes: 132 additions & 0 deletions src/did/util/compare_database_summary.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
"""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:
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}.value mismatch ({val_a} vs {val_b})."
)
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
Loading
Loading