Developers’ Reference

Architecture, build/test/release workflow, documentation pipeline, and contribution rules for TimeToAlign!

This page is the canonical reference for contributing to TimeToAlign!. The conceptual model lives in Concepts and the per-class API lives under API Reference; everything you need to build, test, document, and release the library is here.

The package is named timetoalign for machines (imports, PyPI) and written TimeToAlign! for humans (prose, headings, comments).


1. Repository Layout

The git repository is a multi-project workspace; the library itself lives in timetoalign/.

tta/                                 # Repository root
├── timetoalign/                     # The Python library — the focus of this doc
│   ├── timetoalign/                 # Importable package
│   ├── tests/                       # pytest suite (mirrors the package layout)
│   ├── docs/                        # Documentation source
│   │   ├── page/                    # Quarto site (this site)
│   │   ├── tuto-notebooks/          # Tutorial notebooks (jupytext .py + paired .ipynb)
│   │   └── howto-notebooks/         # How-to notebooks (jupytext .py + paired .ipynb)
│   ├── pyproject.toml               # PEP 621 project metadata + ruff/coverage/pytest config
│   ├── tox.ini                      # Build/test/lint/publish orchestrator
│   ├── .pre-commit-config.yaml      # Pinned formatter/linter versions
│   ├── .github/workflows/           # CI (release-please)
│   └── CHANGELOG.md                 # Auto-generated by release-please — DO NOT edit by hand
├── tta_article/                     # TISMIR manuscript (LaTeX, submitted) — out of scope here
├── pyMeasureMap/                    # Sibling project; standalone
└── dashboard/                       # Sibling project; standalone

Sibling projects (tta_article/, pyMeasureMap/, dashboard/) have their own build infrastructure and are not governed by the rules below.


2. Architecture Overview

2.1 Subpackages

The importable package timetoalign/ is organised into focused subpackages, each with a single responsibility:

Subpackage Responsibility
core/ Foundational types: enums (Domain, TimeUnit, …), Coordinate / IdCoordinate, TimeStamp, IdGenerator; core/fields.py contains the SemanticField bases plus the Arrow translator/builder.
display/ ASCII rendering helpers for timelines, groups, bundles, flows.
maps/ ConversionMap family (LinearMap, TableMap, ChainMap, PiecewiseMap, …).
timelines/ Timeline classes for all six domain × modality combinations, TimelineGroup, regions, flow control, beat grids.
alignment/ MatchClaim, AlignmentAnchor, AlignmentBundle, MatchGraph, MatchLine, WarpMap.
storage/ PyArrow-backed EventData / EventStore storage primitives.
loader/ File-format-specific ingestion.

2.2 Import Direction (MANDATORY)

Subpackages are arranged in strict layers. Imports may only flow downward, from a higher-numbered layer to a lower-numbered one. This is a hard rule: violations create circular-import landmines and are refused at review.

                    ┌──────────────────────────────┐
       Layer 5 ──▶  │            loader            │
                    └──────────────┬───────────────┘
                                   │ may import from layers 0–4
                    ┌──────────────▼───────────────┐
       Layer 4 ──▶  │          alignment           │
                    └──────────────┬───────────────┘
                                   │ may import from layers 0–3
                    ┌──────────────▼───────────────┐
       Layer 3 ──▶  │          timelines           │
                    └──────────────┬───────────────┘
                                   │ may import from layers 0–2
                    ┌──────────────▼───────────────┐
       Layer 2 ──▶  │           storage            │
                    └──────────────┬───────────────┘
                                   │ may import from layers 0–1
                    ┌──────────────▼───────────────┐
       Layer 1 ──▶  │             maps             │
                    └──────────────┬───────────────┘
                                   │ may import from layer 0
                    ┌──────────────▼───────────────┐
       Layer 0 ──▶  │       core       display      │
                    └──────────────────────────────┘
                       (no internal dependencies)

Equivalently:

Subpackage May import from
core/ nothing internal
display/ nothing internal at runtime (TYPE_CHECKING only)
maps/ core
storage/ lower layers as needed; owns EventData and EventStore
timelines/ core, maps, storage
alignment/ lower layers, including timelines
loader/ all lower layers

Storage ownership and timeline dependencies

storage/ is a first-class layer that owns EventData and EventStore, the PyArrow-backed event storage used by timelines and loaders. Timeline instances therefore import storage types at runtime as part of the normal layering:

from timetoalign.storage import EventData

There is no timelinesloader import cycle: loader/ is the upper layer and imports downward to construct storage-backed timelines. Runtime imports from timelines/ to storage/ are legitimate dependencies; keep all imports flowing from a higher layer to a lower one.

2.3 Stable Architectural Invariants

These invariants are tested at the unit level and enforced at review. If you find yourself wanting to break one, raise it as a discussion before coding.

  • Coordinate resolution returns a shared stamp type. TimeStamp, GroupTimestamp, and MatchStamp implement the common Stamp interface; each exposes raw and unit-bearing coordinate access consistently.
  • Coordinate and IdCoordinate are the canonical input types for every method that takes a coordinate value. Raw numbers are accepted for ergonomics; raw numbers are not acceptable as return types — every getter returns a Coordinate.
  • ConversionMap properties belong to a single Timeline. They are not graph edges between timelines.
  • MatchGraph is on-demand and analytical. It is built from claims when needed, not maintained as a system-wide structure.
  • Loaders use a strict two-phase contract. loader.load(*sources) ingests files; loader.create_*() produces domain objects and never takes file paths. The from_file() classmethod is sugar for the common case.
  • All enums live in core/enums.py and subclass FancyStrEnum. Member names are lowercase. The single documented exception is NumberType, whose values are Python type objects.
  • Timeline IDs are systematic: clt/dlt/cpt/dpt/cgt/dgt prefixes for the six domain × modality combinations, with optional role prefixes (score:clt1, perf:dlt1).

For the full conceptual rationale see Concepts and the Glossary.

2.4 Architectural decision log

The invariants below are load-bearing design rules. They are not auto-discoverable from any single docstring; new code is expected to respect them, and breaking changes are expected to be discussed and recorded here.

Pydantic-derived schemas + value projectors. Every scalar (Coordinate, Duration, SpecificPitch, Note, Measure, the pitch / harmony hierarchies, …) is a pydantic BaseModel subclass with model_config = ConfigDict(frozen=True). The corresponding PyArrow pa.StructType is derived once at class-definition time via derive_arrow_struct(model_cls) (see timetoalign/core/fields.py). Computed pydantic fields are deliberately excluded from the derived struct. Individual fields may be denormalised or dropped via register_value_projector(model_cls, field_name, projector): for example, Coordinate.value projects to {value, numerator, denominator} so rationals survive Parquet, and Note.pitch is dropped entirely so pitch lives in its own field (midi_pitch / specific_pitch) rather than as a sub-field of Note.

Union-of-BaseModel rejected. Distinct scalar types live in distinct PyArrow fields. The translator deliberately raises on BaseModel | BaseModel unions: storing them as PyArrow dense_union is forbidden because consumers (DuckDB, polars, pandas) cannot project the union arms cleanly. The required pattern is columnar separation — drop the polymorphic field with register_value_projector(..., lambda *_: []) and expose each scalar type in its own field.

Bulk construction path. The canonical bulk constructor is StructArrayBuilder.from_model(model_cls).build(objects), with build_struct_array(model_cls, objects) as the public facade and build_coordinate_struct_array as the specialised facade for the rational denormalisation of Coordinate / Duration. The builder traverses each scalar once and accumulates per-field arrays. Never model_dump row-wise in production paths. The microbenchmark gate requires the store-builder to be ≥ 2× faster than row-wise model_dump; on Coordinate the observed ratio is ~5×, and the underlying load-bearing claim — vectorised pa.compute operations on a 1M-element field being ~150–210× faster than the equivalent per-object Python loop — is the justification for the columnar architecture as a whole.

Paired scalar + field convention. Every scalar X is followed in the same source file by its paired XField(SemanticField[X]). core/time.py cohabits the time scalars (Coordinate, Duration, IdCoordinate, IdDuration) and their paired fields under a single TimeScalarField parent that consolidates from_field / from_table / metadata plumbing; core/events.py cohabits pitch, harmony, Note, and Measure with their paired fields. Object + ObjectField is the unit of code organisation and must never be split across files.

Raw vs semantic DataField branch. DataField is the abstract base for field wrappers. Raw subclasses (NumericField, StringField, StructField, MapField, plus the struct-shaped numeric branch NumberFieldRationalFieldDenominateNumberField) wrap PyArrow data with type checks and accessors. SemanticField[T] is the strictly-typed bridge between a pydantic scalar T and a struct-shaped raw field; it pins the inner raw type via the _raw_cls ClassVar (default StructField, refined to DenominateNumberField by TimeScalarField).

Two-step typing/grouping algorithm. FlowController converts raw MeasureUnits into typed measures and groups via two passes:

  1. Typing step. Each MeasureUnit is classified as IncompleteMeasure / CompleteMeasure / OverlengthMeasure by comparing its actual duration to the expected time-signature duration, with local context (first/last measure) refining the incomplete position.
  2. Grouping step. Typed measures are consolidated into MeasureGroup subclasses (CompleteMeasureGroup, SplitMeasure, VoltaGroup, IncompleteGroup, OverlengthGroup) so every measure belongs to exactly one group.

Do not use the word “Phase” to refer to these passes in shipped code or docs — say “Typing step” / “Grouping step”.

Unified TimeStamp architecture. TimeStamp, GroupTimestamp, and MatchStamp are the canonical results of coordinate resolution for timelines, groups, and bundles respectively. They share the same Stamp interface for timeline presence, raw coordinates, and unit-bearing coordinate access. MatchGraph remains on-demand (see §2.3).


3. Environment & Installation

3.1 Python

  • Supported: Python 3.11, 3.12, 3.13 (CI matrix).
  • Required: >=3.11 per pyproject.toml.

3.2 Install

From the timetoalign/ directory:

pip install -e ".[dev]"

dev is the convenience extra that pulls in everything: all loaders, plotting, the tutorial Jupyter stack, the docs build chain, the test stack, and the formatter/linter tooling. See pyproject.toml for the full extras hierarchy (midi, partitura, music21, ms3, audio, graphical, plot, scores, loaders, tutorial, docs, testing, all, dev).

3.3 Pre-commit Hook

After installing, register the git hook once per clone:

pre-commit install

The pinned hook versions in .pre-commit-config.yaml are:

Hook Version Settings
black 26.1.0 language_version: python3.11
isort 7.0.0 --profile black --filter-files
flake8 7.3.0 max-line-length=120, __init__.py:F401 ignored, docs/howto-notebooks/*.py:E402 ignored
seed-isort-config 2.2.0
pre-commit-hooks 6.0.0 trailing-whitespace, check-ast/json/xml/yaml, end-of-file-fixer, mixed-line-ending (auto), debug-statements, requirements-txt-fixer

These are pinned deliberately — bump them in a dedicated chore: commit so the diff is isolated.


4. Build, Test, Publish (tox)

tox is the canonical entry point for everything CI-relevant. It uses tox-uv for fast environment creation. All commands run from timetoalign/.

Command Effect
tox Runs the full pytest suite under py311, py312, py313 (skips missing interpreters).
tox -e lint pre-commit run --all-files --show-diff-on-failure.
tox -e build PEP 517 sdist + wheel into ./dist/ via python -m build.
tox -e clean Removes ./build/ and ./dist/.
tox -e publish twine check dist/* then twine upload dist/* (testpypi by default).

To publish to production PyPI explicitly:

tox -e publish -- --repository pypi

The publish env passes TWINE_USERNAME / TWINE_PASSWORD / TWINE_REPOSITORY / TWINE_REPOSITORY_URL through from the environment.

There is no tox -e docs env; documentation is built directly with quartodoc + quarto (see §6).

4.1 Direct pytest for Iteration

Inside the active venv, plain pytest is faster than tox for tight edit-test loops:

pytest                              # full suite, parallel via pytest-xdist (-n auto)
pytest -n 0                         # serial — debugging only
pytest tests/timelines              # one subtree
pytest tests/timelines/test_base.py::test_lock

Coverage is on by default (configured in pyproject.toml [tool.pytest.ini_options]).

4.2 Test Discipline (MANDATORY)

  • Parallel-safe. No global mutable state, no inter-test ordering, use tmp_path not hardcoded /tmp/ paths, no port collisions.
  • Exact assertions. Use exact gold-standard counts, never >=, never “approximate”. If floating-point tolerance is required, document the mathematical reason in the test and the test data README.
  • Cover happy path + edge cases. Boundaries, empty inputs, type mismatches.
  • Property tests for math. ConversionMap-style code uses hypothesis to verify invariants such as inverse(forward(x)) == x.
  • Test data needs a README. Every directory under tests/data/ documents provenance, validation logic, and known discrepancies between loaders.

5. Coding Standards

5.1 Style and Formatting

  • Line length 120 (enforced by black + flake8).
  • from __future__ import annotations is the first non-comment line.
  • Imports grouped: standard library, third-party, local — handled by isort with the black profile.
  • Module logger: module_logger = logging.getLogger(__name__) immediately after imports.
  • Use # region <name> / # endregion <name> to group related class / function definitions inside a module.

5.2 Class Member Order

Inside a class, declare members in this order:

  1. Class variables.
  2. @classmethod @property accessors.
  3. @classmethod factories / helpers.
  4. Nested classes (especially Schema).
  5. __init__.
  6. Magic methods (__eq__, __hash__, __repr__, __str__).
  7. @property accessors.
  8. Public and private methods.

5.3 Typing

  • Every public function signature and class attribute is type-hinted.
  • Use modern generics (list[str], dict[str, int], X | None) — the __future__ annotations import makes these free at runtime on 3.11+.
  • Use typing_extensions.Self for methods returning the instance type.
  • Break circular dependencies with if TYPE_CHECKING: blocks and string annotations, not by collapsing modules.

5.4 Docstrings (Google Style)

Every public module, class, function, and method needs a Google-style docstring. Quartodoc renders them into the API reference site.

def lookup(self, coord: CoordinateSpec) -> TimeStamp:
    """Return the timestamp at ``coord``.

    Args:
        coord: A raw value, ``Coordinate``, or ``IdCoordinate``.

    Returns:
        A `timetoalign.TimeStamp` populated with all synchronous child
        coordinates and conversion-map results.

    Raises:
        ValueError: If ``coord`` is outside the timeline length.
    """

Cross-linking

  • In docstrings: wrap fully-qualified names in backticks (`timetoalign.ConversionMap`); the interlinks filter resolves them.
  • In .qmd pages and notebook markdown: every model term goes through the glossary shortcode: . Bare uses without the shortcode are documentation bugs.
  • When you add a new concept: update glossary.yml and glossary.qmd in the same commit as the implementation.

5.5 Error Handling

  • Never use assert for runtime validation — it disappears under -O.
  • ValueError for invalid arguments.
  • TypeError for unit / type mismatches.
  • RuntimeError (or a custom subclass) for invalid state, e.g. mutating a locked timeline.

5.6 Pitch Spelling

Normalise all input to canonical Unicode characters:

  • Sharp: (U+266F)
  • Flat: (U+266D)

#, b, -, etc. are converted at the boundary.


6. Documentation Site

The docs site is built with Quarto + quartodoc and lives at https://timetoalign.github.io/.

6.1 Building Locally

cd docs/page
quartodoc build           # regenerate the API reference pages from docstrings
quarto render             # build the full site into _site/

The Quarto pre-render hook automatically runs sync_notebooks.py (see §6.3) to refresh tutorial and how-to notebooks before rendering.

6.2 Site Structure (Diátaxis)

The site follows the Diátaxis framework:

  • Tutorials (tutorials.qmd + tutorials/): learning-oriented, step-by-step.
  • How-to Guides (howto.qmd + howto/): task-oriented, focused recipes.
  • Explanation (concepts.qmd, glossary.qmd): understanding-oriented.
  • API Reference (reference.qmd + auto-generated reference/): information-oriented; this Developers’ Reference lives here too.

The navigation (top navbar + per-section sidebar) is configured in _quarto.yml.

6.3 Notebook Integration Pipeline

Tutorial and how-to notebooks live as jupytext py:percent files in docs/tuto-notebooks/ and docs/howto-notebooks/. The paired .ipynb files are regenerated mechanically; the .py is the source of truth in git.

The pipeline is driven by docs/page/notebooks.csv, with one row per notebook:

Column Meaning
section Either tutorials or howto. Selects the source directory and target subdirectory of the rendered site.
source Filename of the .py jupytext source.
slug Stem used for the rendered .ipynb (without extension).
title Title injected as Quarto YAML front matter.
description Short description used in listing pages.

docs/page/sync_notebooks.py (run as the Quarto pre-render step) does three things per row:

  1. Runs jupytext --sync --execute on the .py source, regenerating the paired .ipynb with fresh outputs.
  2. Copies the resulting .ipynb into docs/page/{tutorials,howto}/<slug>.ipynb.
  3. Replaces the notebook metadata with a Quarto-friendly YAML cell built from the CSV row.

A SHA-256 cache (docs/page/.sync_state.json) skips notebooks whose .py source has not changed.

Adding a new how-to / tutorial notebook

  1. Drop the new .py (jupytext py:percent) into docs/tuto-notebooks/ or docs/howto-notebooks/.
  2. Add a row to docs/page/notebooks.csv.
  3. Add a sidebar entry under the appropriate section in docs/page/_quarto.yml.
  4. If the notebook introduces a new model term, also update glossary.yml and glossary.qmd.
  5. Run cd docs/page && python sync_notebooks.py --verbose once locally to confirm it executes cleanly.
  6. Run quartodoc build && quarto render to confirm it renders.

Notebook style rules

  • Markdown cells use the shortcode for every model term. A bare “Timeline” / “ConversionMap” / etc. is a documentation bug.
  • Use Loader.from_file() rather than the two-phase load() + create_*() pattern in tutorials. The two-phase form is library-internal.
  • Coordinate conversion is demonstrated through a TimeStamp or MatchStamp, which expose the resolved coordinates directly.

6.4 Jupytext and Quarto: Upgrade-Only

Jupytext and Quarto versions may only ever be upgraded, never downgraded.

Both tools rewrite notebook structure on save; downgrading silently introduces incompatible changes that break the diff history and may corrupt outputs across the entire notebook corpus. If a contributor’s local install is older than the version that last touched the notebooks, they must upgrade before running sync_notebooks.py.

This rule applies equally to CI environments — pin versions upward only.

6.5 quartodoc API Reference

The API reference under docs/page/reference/ is fully generated from the live docstrings. Do not hand-edit those files; they are overwritten by quartodoc build. To add a class to the reference:

  1. Make sure the class is exported from timetoalign/__init__.py (or list it with an explicit name: / package: entry).
  2. Add it to the appropriate contents: list under quartodoc: in docs/page/_quarto.yml.
  3. quartodoc build regenerates the page.

7. Conventional Commits & Releases

The library is released by release-please, configured in .github/workflows/release-please.yml with release-type: python and package-name: timetoalign. The action runs on every push to main and:

  1. Reads commit messages since the last release tag.
  2. Computes the next semantic version from those messages.
  3. Opens (or updates) a release PR that bumps pyproject.toml’s version and rewrites CHANGELOG.md.
  4. When the release PR is merged, tags the commit and creates a GitHub release.

Because the version bump is computed from commit messages, every commit that lands on main must follow Conventional Commits.

7.1 Commit Types

Prefix Effect on version Use for
feat: minor bump New user-facing capability.
fix: patch bump Bug fix.
perf: patch bump Performance improvement (no behaviour change).
docs: none Documentation only.
style: none Formatting / whitespace.
refactor: none Restructuring with no behaviour change.
test: none Adding or correcting tests.
build: none Build system or dependency changes.
ci: none CI configuration changes.
chore: none Repo housekeeping with no src/test impact.
revert: depends on reverted Revert of a previous commit.

Optional scopes are encouraged for clarity: feat(loader): …, fix(maps): ….

7.2 Breaking Changes (MAJOR Bump)

A breaking change triggers a major version bump. Mark it both ways:

  1. Add ! after the type, e.g. refactor!:, feat!:.
  2. Include a BREAKING CHANGE: footer describing the migration.
refactor!: rename TimelineGroup.get_timestamp row lookup

BREAKING CHANGE: row-index lookups now use get_timestamp_at_index(index).
The coordinate-resolving get_timestamp_at(coordinate, timeline_id) method
is unchanged and returns a GroupTimestamp.

The ! is what release-please reads to compute the bump; the BREAKING CHANGE: footer is what readers of the changelog rely on. Use both.

7.3 Manual Edits Are Forbidden

  • Do not edit pyproject.toml’s version by hand.
  • Do not edit CHANGELOG.md by hand.

Both are owned by release-please. Manual edits will be overwritten or, worse, will fight the next release PR.


8. Branching and Pull Requests

  • main is the release branch; release-please runs against it.
  • Feature work happens on topic branches; squash-merge into main with a Conventional Commit message that becomes the merge commit.
  • The PR title also follows Conventional Commits — release-please reads the merge commit, but a consistent title makes the PR list scannable.
  • Run tox -e lint and tox (or at least targeted pytest) locally before opening a PR.

9. IDE

PyCharm is the preferred IDE. The repo is set up to work cleanly with it out of the box:

  • Import the timetoalign/ directory as a PyCharm project (not the repo root — sibling projects have their own settings).
  • Configure the project interpreter to a venv with pip install -e ".[dev]" applied.
  • Enable the Black and isort integrations and point them at the project tools (PyCharm reads .pre-commit-config.yaml for the pinned versions; mirror them in Settings → Tools → Black and Settings → Editor → Code Style → Python → Imports).
  • Enable Settings → Editor → Inspections → Python → Type checker with strict mode if you want the same level of typing the codebase already enforces.
  • The flake8 line length is 120; set Settings → Editor → Code Style → Python → Hard wrap at to 120 to match.

VS Code, Neovim, and other editors all work — the formatters and linters are externally driven by pre-commit and tox, so editor choice is not load-bearing — but PyCharm is what the maintainers use day-to-day.


10. Where to Look Next